Add human-friendly ID support.

Allows a user to interact with certain models (image, flavors, and
servers currently) using a human-friendly identifier which is a
slugified form of the model name.

Example:

    nova boot --image debian-6-squeeze --flavor 256mb-instance myinst

Change-Id: I43dbedac3493d010c1ec9ba8b8bb1007ff7ac499
This commit is contained in:
Rick Harris 2012-03-06 00:33:37 +00:00
parent 0528368fb9
commit b22ec22336
8 changed files with 84 additions and 32 deletions

View File

@ -82,25 +82,23 @@ class Manager(utils.HookableMixin):
except KeyError: except KeyError:
pass pass
with self.uuid_cache(obj_class, mode="w"): with self.completion_cache('human_id', obj_class, mode="w"):
return [obj_class(self, res, loaded=True) for res in data if res] with self.completion_cache('uuid', obj_class, mode="w"):
return [obj_class(self, res, loaded=True)
for res in data if res]
@contextlib.contextmanager @contextlib.contextmanager
def uuid_cache(self, obj_class, mode): def completion_cache(self, cache_type, obj_class, mode):
""" """
Cache UUIDs for bash autocompletion. The completion cache store items that can be used for bash
autocompletion, like UUIDs or human-friendly IDs.
The UUID cache works by checking to see whether an ID is UUID-like when A resource listing will clear and repopulate the cache.
we create a resource object (e.g. a Image or a Server), and if it is,
we add it to a local cache file. We maintain one cache file per
resource type so that we can refresh them independently.
A resource listing will clear and repopulate the UUID cache. A resource create will append to the cache.
A resource create will append to the UUID cache.
Delete is not handled because listings are assumed to be performed Delete is not handled because listings are assumed to be performed
often enough to keep the UUID cache reasonably up-to-date. often enough to keep the cache reasonably up-to-date.
""" """
base_dir = utils.env('NOVACLIENT_UUID_CACHE_DIR', base_dir = utils.env('NOVACLIENT_UUID_CACHE_DIR',
default="~/.novaclient") default="~/.novaclient")
@ -111,10 +109,10 @@ class Manager(utils.HookableMixin):
url = utils.env('OS_URL', 'NOVA_URL') url = utils.env('OS_URL', 'NOVA_URL')
uniqifier = hashlib.md5(username + url).hexdigest() uniqifier = hashlib.md5(username + url).hexdigest()
uuid_cache_dir = os.path.expanduser(os.path.join(base_dir, uniqifier)) cache_dir = os.path.expanduser(os.path.join(base_dir, uniqifier))
try: try:
os.makedirs(uuid_cache_dir, 0755) os.makedirs(cache_dir, 0755)
except OSError as e: except OSError as e:
if e.errno == errno.EEXIST: if e.errno == errno.EEXIST:
pass pass
@ -122,10 +120,13 @@ class Manager(utils.HookableMixin):
raise raise
resource = obj_class.__name__.lower() resource = obj_class.__name__.lower()
filename = os.path.join(uuid_cache_dir, "%s-uuid-cache" % resource) filename = "%s-%s-cache" % (resource, cache_type.replace('_', '-'))
path = os.path.join(cache_dir, filename)
cache_attr = "_%s_cache" % cache_type
try: try:
self._uuid_cache = open(filename, mode) setattr(self, cache_attr, open(path, mode))
except IOError: except IOError:
# NOTE(kiall): This is typicaly a permission denied while # NOTE(kiall): This is typicaly a permission denied while
# attempting to write the cache file. # attempting to write the cache file.
@ -134,13 +135,15 @@ class Manager(utils.HookableMixin):
try: try:
yield yield
finally: finally:
if hasattr(self, '_uuid_cache'): cache = getattr(self, cache_attr, None)
self._uuid_cache.close() if cache:
del self._uuid_cache cache.close()
delattr(self, cache_attr)
def write_uuid_to_cache(self, uuid): def write_to_completion_cache(self, cache_type, val):
if hasattr(self, '_uuid_cache'): cache = getattr(self, "_%s_cache" % cache_type, None)
self._uuid_cache.write("%s\n" % uuid) if cache:
cache.write("%s\n" % val)
def _get(self, url, response_key=None): def _get(self, url, response_key=None):
resp, body = self.api.client.get(url) resp, body = self.api.client.get(url)
@ -155,7 +158,8 @@ class Manager(utils.HookableMixin):
if return_raw: if return_raw:
return body[response_key] return body[response_key]
with self.uuid_cache(self.resource_class, mode="a"): with self.completion_cache('human_id', self.resource_class, mode="a"):
with self.completion_cache('uuid', self.resource_class, mode="a"):
return self.resource_class(self, body[response_key]) return self.resource_class(self, body[response_key])
def _delete(self, url): def _delete(self, url):
@ -282,6 +286,8 @@ class Resource(object):
:param info: dictionary representing resource attributes :param info: dictionary representing resource attributes
:param loaded: prevent lazy-loading if set to True :param loaded: prevent lazy-loading if set to True
""" """
HUMAN_ID = False
def __init__(self, manager, info, loaded=False): def __init__(self, manager, info, loaded=False):
self.manager = manager self.manager = manager
self._info = info self._info = info
@ -292,7 +298,20 @@ class Resource(object):
# enter an infinite loop of __getattr__ -> get -> __init__ -> # enter an infinite loop of __getattr__ -> get -> __init__ ->
# __getattr__ -> ... # __getattr__ -> ...
if 'id' in self.__dict__ and len(str(self.id)) == 36: if 'id' in self.__dict__ and len(str(self.id)) == 36:
self.manager.write_uuid_to_cache(self.id) self.manager.write_to_completion_cache('uuid', self.id)
human_id = self.human_id
if human_id:
self.manager.write_to_completion_cache('human_id', human_id)
@property
def human_id(self):
"""Subclasses may override this provide a pretty ID which can be used
for bash completion.
"""
if 'name' in self.__dict__ and self.HUMAN_ID:
return utils.slugify(self.name)
return None
def _add_details(self, info): def _add_details(self, info):
for (k, v) in info.iteritems(): for (k, v) in info.iteritems():

View File

@ -1,8 +1,10 @@
import os import os
import prettytable import re
import sys import sys
import uuid import uuid
import prettytable
from novaclient import exceptions from novaclient import exceptions
@ -166,6 +168,11 @@ def find_resource(manager, name_or_id):
except (ValueError, exceptions.NotFound): except (ValueError, exceptions.NotFound):
pass pass
try:
return manager.find(human_id=name_or_id)
except exceptions.NotFound:
pass
# finally try to find entity by name # finally try to find entity by name
try: try:
return manager.find(name=name_or_id) return manager.find(name=name_or_id)
@ -226,3 +233,23 @@ def import_class(import_str):
mod_str, _sep, class_str = import_str.rpartition('.') mod_str, _sep, class_str = import_str.rpartition('.')
__import__(mod_str) __import__(mod_str)
return getattr(sys.modules[mod_str], class_str) return getattr(sys.modules[mod_str], class_str)
_slugify_strip_re = re.compile(r'[^\w\s-]')
_slugify_hyphenate_re = re.compile(r'[-\s]+')
# http://code.activestate.com/recipes/
# 577257-slugify-make-a-string-usable-in-a-url-or-filename/
def slugify(value):
"""
Normalizes string, converts to lowercase, removes non-alpha characters,
and converts spaces to hyphens.
From Django's "django/template/defaultfilters.py".
"""
import unicodedata
if not isinstance(value, unicode):
value = unicode(value)
value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore')
value = unicode(_slugify_strip_re.sub('', value).strip().lower())
return _slugify_hyphenate_re.sub('-', value)

View File

@ -64,8 +64,8 @@ class BootingManagerWithFind(base.ManagerWithFind):
""" """
body = {"server": { body = {"server": {
"name": name, "name": name,
"imageRef": base.getid(image), "imageRef": str(base.getid(image)),
"flavorRef": base.getid(flavor), "flavorRef": str(base.getid(flavor)),
}} }}
if userdata: if userdata:
if hasattr(userdata, 'read'): if hasattr(userdata, 'read'):

View File

@ -10,6 +10,8 @@ class Flavor(base.Resource):
""" """
A flavor is an available hardware configuration for a server. A flavor is an available hardware configuration for a server.
""" """
HUMAN_ID = True
def __repr__(self): def __repr__(self):
return "<Flavor: %s>" % self.name return "<Flavor: %s>" % self.name

View File

@ -10,6 +10,8 @@ class Image(base.Resource):
""" """
An image is a collection of files used to create or rebuild a server. An image is a collection of files used to create or rebuild a server.
""" """
HUMAN_ID = True
def __repr__(self): def __repr__(self):
return "<Image: %s>" % self.name return "<Image: %s>" % self.name

View File

@ -29,6 +29,8 @@ REBOOT_SOFT, REBOOT_HARD = 'SOFT', 'HARD'
class Server(base.Resource): class Server(base.Resource):
HUMAN_ID = True
def __repr__(self): def __repr__(self):
return "<Server: %s>" % self.name return "<Server: %s>" % self.name

View File

@ -45,8 +45,8 @@ def _boot(cs, args, reservation_id=None, min_count=None, max_count=None):
if not args.flavor: if not args.flavor:
raise exceptions.CommandError("you need to specify a Flavor ID ") raise exceptions.CommandError("you need to specify a Flavor ID ")
flavor = args.flavor flavor = _find_flavor(cs, args.flavor)
image = args.image image = _find_image(cs, args.image)
meta = dict(v.split('=', 1) for v in args.meta) meta = dict(v.split('=', 1) for v in args.meta)

View File

@ -7,8 +7,8 @@ _nova()
opts="$(nova bash_completion)" opts="$(nova bash_completion)"
UUID_CACHE=~/.novaclient/*/*-uuid-cache COMPLETION_CACHE=~/.novaclient/*/*-cache
opts+=" "$(cat $UUID_CACHE 2> /dev/null | tr '\n' ' ') opts+=" "$(cat $COMPLETION_CACHE 2> /dev/null | tr '\n' ' ')
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
} }