Removes zones

As per the openstack meeting today, we will be removing zones from nova
for the Essex release.  Therefore, we should remove it from
python-novaclient.

Change-Id: Iccb363e4d7f24f3e0808dd9cda3b7558be76bae2
This commit is contained in:
Chris Behrens 2012-02-14 22:04:14 +00:00
parent 7601bef9ef
commit 6fe77972f5
12 changed files with 20 additions and 499 deletions

View File

@ -88,7 +88,7 @@ You'll find complete documentation on the shell by running
aggregate-set-metadata
Update the metadata associated with the aggregate
aggregate-update Update the aggregate's name and optionally
availablity zone
availability zone
backup Backup a server.
backup-schedule Show or edit the backup schedule for a server.
backup-schedule-delete
@ -122,7 +122,7 @@ You'll find complete documentation on the shell by running
keypair-delete Delete keypair by its id
keypair-list Show a list of keypairs for a user
list List active servers.
migrate Migrate a server to a new host in the same zone.
migrate Migrate a server to a new host.
reboot Reboot a server.
rebuild Shutdown, re-image, and re-boot a server.
remove-fixed-ip Remove an IP address from a server.
@ -166,12 +166,6 @@ You'll find complete documentation on the shell by running
Show details about a snapshot.
x509-create-cert Create x509 cert for a user in tenant
x509-get-root-cert Fetches the x509 root cert.
zone Show or edit a Child Zone
zone-add Add a Child Zone.
zone-boot Boot a server, considering Zones.
zone-delete Remove a Child Zone.
zone-info Show the capabilities for this Zone.
zone-list List all the immediate Child Zones.
Optional arguments:

View File

@ -201,7 +201,7 @@ class ManagerWithFind(Manager):
class BootingManagerWithFind(ManagerWithFind):
"""Like a `ManagerWithFind`, but has the ability to boot servers."""
def _boot(self, resource_url, response_key, name, image, flavor,
ipgroup=None, meta=None, files=None, zone_blob=None,
ipgroup=None, meta=None, files=None,
reservation_id=None, return_raw=False, min_count=None,
max_count=None, **kwargs):
"""
@ -219,9 +219,6 @@ class BootingManagerWithFind(ManagerWithFind):
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 return_raw: If True, don't try to coearse the result into
a Resource object.
@ -237,8 +234,6 @@ class BootingManagerWithFind(ManagerWithFind):
body["server"]["metadata"] = meta
if reservation_id:
body["server"]["reservation_id"] = reservation_id
if zone_blob:
body["server"]["blob"] = zone_blob
if not min_count:
min_count = 1

View File

@ -60,7 +60,7 @@ class AggregateManager(base.ManagerWithFind):
"aggregate")
def update(self, aggregate, values):
"""Update the name and/or availablity zone."""
"""Update the name and/or availability zone."""
body = {'aggregate': values}
result = self._update("/os-aggregates/%s" % base.getid(aggregate),
body)

View File

@ -25,7 +25,7 @@ from novaclient import base
class BootingManagerWithFind(base.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, zone_blob=None, userdata=None,
meta=None, files=None, userdata=None,
reservation_id=None, return_raw=False, min_count=None,
max_count=None, security_groups=None, key_name=None,
availability_zone=None, block_device_mapping=None, nics=None,
@ -44,16 +44,14 @@ class BootingManagerWithFind(base.ManagerWithFind):
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 return_raw: If True, don't try to coearse the result into
a Resource object.
:param security_groups: list of security group names
:param key_name: (optional extension) name of keypair to inject into
the instance
:param availability_zone: The :class:`Zone`.
:param availability_zone: Name of the availability zone for instance
placement.
:param block_device_mapping: A dict of block device mappings for this
server.
:param nics: (optional extension) an ordered list of nics to be
@ -75,8 +73,6 @@ class BootingManagerWithFind(base.ManagerWithFind):
body["server"]["metadata"] = meta
if reservation_id:
body["server"]["reservation_id"] = reservation_id
if zone_blob:
body["server"]["blob"] = zone_blob
if key_name:
body["server"]["key_name"] = key_name
if scheduler_hints:

View File

@ -17,7 +17,6 @@ from novaclient.v1_1 import usage
from novaclient.v1_1 import virtual_interfaces
from novaclient.v1_1 import volumes
from novaclient.v1_1 import volume_snapshots
from novaclient.v1_1 import zones
class Client(object):
@ -59,7 +58,6 @@ class Client(object):
self.volumes = volumes.VolumeManager(self)
self.volume_snapshots = volume_snapshots.SnapshotManager(self)
self.keypairs = keypairs.KeypairManager(self)
self.zones = zones.ZoneManager(self)
self.quotas = quotas.QuotaSetManager(self)
self.security_groups = security_groups.SecurityGroupManager(self)
self.security_group_rules = \

View File

@ -133,7 +133,7 @@ class Server(base.Resource):
def migrate(self):
"""
Migrate a server to a new host in the same zone.
Migrate a server to a new host.
"""
self.manager.migrate(self)
@ -362,7 +362,7 @@ class ServerManager(local_base.BootingManagerWithFind):
"actions")
def create(self, name, image, flavor, meta=None, files=None,
zone_blob=None, reservation_id=None, min_count=None,
reservation_id=None, min_count=None,
max_count=None, security_groups=None, userdata=None,
key_name=None, availability_zone=None,
block_device_mapping=None, nics=None, scheduler_hints=None,
@ -383,16 +383,14 @@ class ServerManager(local_base.BootingManagerWithFind):
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 userdata: user data to pass to be exposed by the metadata
server this can be a file type object as well or a
string.
:param reservation_id: a UUID for the set of servers being requested.
:param key_name: (optional extension) name of previously created
keypair to inject into the instance.
:param availability_zone: The :class:`Zone`.
:param availability_zone: Name of the availability zone for instance
placement.
:param block_device_mapping: (optional extension) A dict of block
device mappings for this server.
:param nics: (optional extension) an ordered list of nics to be
@ -411,7 +409,7 @@ class ServerManager(local_base.BootingManagerWithFind):
boot_args = [name, image, flavor]
boot_kwargs = dict(
meta=meta, files=files, userdata=userdata, zone_blob=zone_blob,
meta=meta, files=files, userdata=userdata,
reservation_id=reservation_id, min_count=min_count,
max_count=max_count, security_groups=security_groups,
key_name=key_name, availability_zone=availability_zone,
@ -484,7 +482,7 @@ class ServerManager(local_base.BootingManagerWithFind):
def migrate(self, server):
"""
Migrate a server to a new host in the same zone.
Migrate a server to a new host.
:param server: The :class:`Server` (or its ID).
"""

View File

@ -153,7 +153,7 @@ def _boot(cs, args, reservation_id=None, min_count=None, max_count=None):
@utils.arg('--availability_zone',
default=None,
metavar='<availability-zone>',
help="zone id.")
help="The availability zone for instance placement.")
@utils.arg('--security_groups',
default=None,
metavar='<security_groups>',
@ -207,61 +207,6 @@ def do_boot(cs, args):
utils.print_dict(info)
@utils.arg('--flavor',
default=None,
metavar='<flavor>',
help="Flavor ID (see 'nova flavor-list')")
@utils.arg('--image',
default=None,
metavar='<image>',
help="Image ID (see 'nova image-list').")
@utils.arg('--meta',
metavar="<key=value>",
action='append',
default=[],
help="Record arbitrary key/value metadata. "\
"May be give multiple times.")
@utils.arg('--file',
metavar="<dst-path=src-path>",
action='append',
dest='files',
default=[],
help="Store arbitrary files from <src-path> locally to <dst-path> "\
"on the new server. You may store up to 5 files.")
@utils.arg('--reservation_id',
default=None,
metavar='<reservation_id>',
help="Reservation ID (a UUID). "\
"If unspecified will be generated by the server.")
@utils.arg('--min_instances',
default=None,
type=int,
metavar='<number>',
help="The minimum number of instances to build. "\
"Defaults to 1.")
@utils.arg('--max_instances',
default=None,
type=int,
metavar='<number>',
help="The maximum number of instances to build. "\
"Defaults to 'min_instances' setting.")
@utils.arg('name', metavar='<name>', help='Name for the new server')
def do_zone_boot(cs, args):
"""Boot a new server, potentially across Zones."""
boot_args, boot_kwargs = _boot(cs,
args,
reservation_id=args.reservation_id,
min_count=args.min_instances,
max_count=args.max_instances)
extra_boot_kwargs = utils.get_resource_manager_extra_kwargs(
do_zone_boot, args)
boot_kwargs.update(extra_boot_kwargs)
reservation_id = cs.zones.boot(*boot_args, **boot_kwargs)
print "Reservation ID=", reservation_id
def _translate_flavor_keys(collection):
convert = [('ram', 'memory_mb'), ('disk', 'local_gb')]
for item in collection:
@ -431,14 +376,6 @@ def do_image_delete(cs, args):
metavar='<reservation_id>',
default=None,
help='Only return instances that match reservation_id.')
@utils.arg('--recurse_zones',
dest='recurse_zones',
metavar='<0|1>',
nargs='?',
type=int,
const=1,
default=0,
help='Recurse through all zones if set.')
@utils.arg('--ip',
dest='ip',
metavar='<ip_regexp>',
@ -490,12 +427,10 @@ def do_image_delete(cs, args):
help='Display information from all tenants (Admin only).')
def do_list(cs, args):
"""List active servers."""
recurse_zones = args.recurse_zones
all_tenants = int(os.environ.get("ALL_TENANTS", args.all_tenants))
search_opts = {
'all_tenants': all_tenants,
'reservation_id': args.reservation_id,
'recurse_zones': recurse_zones,
'ip': args.ip,
'ip6': args.ip6,
'name': args.name,
@ -505,10 +440,7 @@ def do_list(cs, args):
'host': args.host,
'instance_name': args.instance_name}
if recurse_zones:
id_col = 'UUID'
else:
id_col = 'ID'
id_col = 'ID'
columns = [id_col, 'Name', 'Status', 'Networks']
formatters = {'Networks': utils._format_servers_list_networks}
@ -738,80 +670,6 @@ def _find_flavor(cs, flavor):
return cs.flavors.find(ram=flavor)
# --zone_username is required since --username is already used.
@utils.arg('zone', metavar='<zone_id>', help='ID of the zone', default=None)
@utils.arg('--api_url', dest='api_url', default=None, help='New URL.')
@utils.arg('--zone_username', dest='zone_username', default=None,
help='New zone username.')
@utils.arg('--zone_password', dest='zone_password', default=None,
help='New password.')
@utils.arg('--weight_offset', dest='weight_offset', default=None,
help='Child Zone weight offset.')
@utils.arg('--weight_scale', dest='weight_scale', default=None,
help='Child Zone weight scale.')
def do_zone(cs, args):
"""Show or edit a child zone. No zone arg for this zone."""
zone = cs.zones.get(args.zone)
# If we have some flags, update the zone
zone_delta = {}
if args.api_url:
zone_delta['api_url'] = args.api_url
if args.zone_username:
zone_delta['username'] = args.zone_username
if args.zone_password:
zone_delta['password'] = args.zone_password
if args.weight_offset:
zone_delta['weight_offset'] = args.weight_offset
if args.weight_scale:
zone_delta['weight_scale'] = args.weight_scale
if zone_delta:
zone.update(**zone_delta)
else:
utils.print_dict(zone._info)
def do_zone_info(cs, args):
"""Get this zones name and capabilities."""
zone = cs.zones.info()
utils.print_dict(zone._info)
@utils.arg('zone_name', metavar='<zone_name>',
help='Name of the child zone being added.')
@utils.arg('api_url', metavar='<api_url>', help="URL for the Zone's Auth API")
@utils.arg('--zone_username', metavar='<zone_username>',
help='Optional Authentication username. (Default=None)',
default=None)
@utils.arg('--zone_password', metavar='<zone_password>',
help='Authentication password. (Default=None)',
default=None)
@utils.arg('--weight_offset', metavar='<weight_offset>',
help='Child Zone weight offset (Default=0.0))',
default=0.0)
@utils.arg('--weight_scale', metavar='<weight_scale>',
help='Child Zone weight scale (Default=1.0).',
default=1.0)
def do_zone_add(cs, args):
"""Add a new child zone."""
zone = cs.zones.create(args.zone_name, args.api_url,
args.zone_username, args.zone_password,
args.weight_offset, args.weight_scale)
utils.print_dict(zone._info)
@utils.arg('zone', metavar='<zone>', help='Name or ID of the zone')
def do_zone_delete(cs, args):
"""Delete a zone."""
cs.zones.delete(args.zone)
def do_zone_list(cs, args):
"""List the children of a zone."""
utils.print_list(cs.zones.list(), ['ID', 'Name', 'Is Active', \
'API URL', 'Weight Offset', 'Weight Scale'])
@utils.arg('server', metavar='<server>', help='Name or ID of server.')
@utils.arg('network_id', metavar='<network_id>', help='Network ID.')
def do_add_fixed_ip(cs, args):
@ -1439,7 +1297,7 @@ def do_aggregate_list(cs, args):
@utils.arg('name', metavar='<name>', help='Name of aggregate.')
@utils.arg('availability_zone', metavar='<availability_zone>',
help='The availablity zone of the aggregate.')
help='The availability zone of the aggregate.')
def do_aggregate_create(cs, args):
"""Create a new aggregate with the specified details."""
aggregate = cs.aggregates.create(args.name, args.availability_zone)
@ -1456,9 +1314,9 @@ def do_aggregate_delete(cs, args):
@utils.arg('id', metavar='<id>', help='Aggregate id to udpate.')
@utils.arg('name', metavar='<name>', help='Name of aggregate.')
@utils.arg('availability_zone', metavar='<availability_zone>',
help='The availablity zone of the aggregate.', nargs='?')
help='The availability zone of the aggregate.', nargs='?')
def do_aggregate_update(cs, args):
"""Update the aggregate's name and optionally availablity zone."""
"""Update the aggregate's name and optionally availability zone."""
updates = {"name": args.name}
if args.availability_zone:
updates["availability_zone"] = args.availability_zone

View File

@ -1,198 +0,0 @@
# 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 import base
from novaclient.v1_1 import base as local_base
class Weighting(base.Resource):
def __init__(self, manager, info, loaded=False):
self.name = "n/a"
super(Weighting, self).__init__(manager, info, loaded)
def __repr__(self):
return "<Weighting: %s>" % 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, loaded=False):
self.name = "n/a"
self.is_active = "n/a"
self.capabilities = "n/a"
super(Zone, self).__init__(manager, info, loaded)
def __repr__(self):
return "<Zone: %s>" % 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(local_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, zone_name, api_url, username, password,
weight_offset=0.0, weight_scale=1.0):
"""
Create a new child zone.
:param zone_name: The child zone's name.
:param api_url: The child zone's auth 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": {
"name": zone_name,
"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, meta=None, files=None,
zone_blob=None, reservation_id=None, min_count=None,
max_count=None, **kwargs):
"""
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 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,
meta=meta, files=files,
zone_blob=zone_blob, reservation_id=reservation_id,
return_raw=True, min_count=min_count,
max_count=max_count, **kwargs)
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)

View File

@ -502,50 +502,6 @@ class FakeHTTPClient(base_client.HTTPClient):
def delete_images_1_metadata_test_key(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 body.keys() == ['zone']
fakes.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 body.keys() == ['zone']
fakes.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)
#
# Keypairs
#

View File

@ -57,7 +57,7 @@ class AggregatesTest(utils.TestCase):
cs.assert_called('PUT', '/os-aggregates/2', body)
self.assertTrue(isinstance(result2, aggregates.Aggregate))
def test_update_with_availablity_zone(self):
def test_update_with_availability_zone(self):
aggregate = cs.aggregates.get_details("1")
values = {"name": "foo", "availability_zone": "new_zone"}
body = {"aggregate": values}

View File

@ -342,7 +342,7 @@ class ShellTest(utils.TestCase):
body = {"aggregate": {"name": "new_name"}}
self.assert_called('PUT', '/os-aggregates/1', body)
def test_aggregate_update_with_availablity_zone(self):
def test_aggregate_update_with_availability_zone(self):
self.run_command('aggregate-update 1 foo new_zone')
body = {"aggregate": {"name": "foo", "availability_zone": "new_zone"}}
self.assert_called('PUT', '/os-aggregates/1', body)

View File

@ -1,76 +0,0 @@
from novaclient.v1_1 import zones
from tests import utils
from tests.v1_1 import fakes
os = fakes.FakeClient()
class ZonesTest(utils.TestCase):
def test_list_zones(self):
sl = os.zones.list()
os.assert_called('GET', '/zones/detail')
[self.assertTrue(isinstance(s, zones.Zone)) for s in sl]
def test_list_zones_undetailed(self):
sl = os.zones.list(detailed=False)
os.assert_called('GET', '/zones')
[self.assertTrue(isinstance(s, zones.Zone)) for s in sl]
def test_get_zone_details(self):
s = os.zones.get(1)
os.assert_called('GET', '/zones/1')
self.assertTrue(isinstance(s, zones.Zone))
self.assertEqual(s.id, 1)
self.assertEqual(s.api_url, 'http://foo.com')
def test_create_zone(self):
s = os.zones.create(zone_name='child_zone',
api_url="http://foo.com",
username='bob',
password='xxx')
os.assert_called('POST', '/zones')
self.assertTrue(isinstance(s, zones.Zone))
def test_update_zone(self):
s = os.zones.get(1)
# Update via instance
s.update(api_url='http://blah.com')
os.assert_called('PUT', '/zones/1')
s.update(api_url='http://blah.com', username='alice', password='xxx')
os.assert_called('PUT', '/zones/1')
# Silly, but not an error
s.update()
# Update via manager
os.zones.update(s, api_url='http://blah.com')
os.assert_called('PUT', '/zones/1')
os.zones.update(1, api_url='http://blah.com')
os.assert_called('PUT', '/zones/1')
os.zones.update(s, api_url='http://blah.com', username='fred',
password='zip')
os.assert_called('PUT', '/zones/1')
def test_delete_zone(self):
s = os.zones.get(1)
s.delete()
os.assert_called('DELETE', '/zones/1')
os.zones.delete(1)
os.assert_called('DELETE', '/zones/1')
os.zones.delete(s)
os.assert_called('DELETE', '/zones/1')
def test_find_zone(self):
s = os.zones.find(password='qwerty')
os.assert_called('GET', '/zones/detail')
self.assertEqual(s.username, 'bob')
# Find with multiple results returns the first item
s = os.zones.find(api_url='http://foo.com')
sl = os.zones.findall(api_url='http://foo.com')
self.assertEqual(sl[0], s)
self.assertEqual([s.id for s in sl], [1, 2])