from __future__ import unicode_literals
import json
import string
import re
from uuid import UUID
from six import string_types, text_type
from functools import wraps
from datetime import datetime
import itertools
import logging
try:
from UserDict import UserDict
from UserList import UserList
except ImportError:
from collections import UserDict, UserList
import datrie
from keystoneauth1.exceptions.http import HttpError
from prompt_toolkit.completion import Completion
from .utils import FQName, Path, Observable, to_json
from .exceptions import ResourceNotFound, ResourceMissing, \
CollectionNotFound, ChildrenExists, BackRefsExists, IsSystemResource
from .context import Context
logger = logging.getLogger(__name__)
def http_error_handler(f):
"""Handle 404 errors returned by the API server
"""
def hrefs_to_resources(hrefs):
for href in hrefs.replace(',', '').split():
type, uuid = href.split('/')[-2:]
yield Resource(type, uuid=uuid)
def hrefs_list_to_resources(hrefs_list):
for href in eval(hrefs_list):
type, uuid = href.split('/')[-2:]
yield Resource(type, uuid=uuid)
@wraps(f)
def wrapper(self, *args, **kwargs):
try:
return f(self, *args, **kwargs)
except HttpError as e:
if e.http_status == 404:
# remove previously created resource
# from the cache
self.emit('deleted', self)
if isinstance(self, Resource):
raise ResourceNotFound(resource=self)
elif isinstance(self, Collection):
raise CollectionNotFound(collection=self)
elif e.http_status == 409:
# contrail 3.2
matches = re.match(r'^Delete when children still present: (\[[^]]*\])($| \(HTTP 409\)$)', e.message)
if matches:
raise ChildrenExists(
resources=list(hrefs_list_to_resources(matches.group(1))))
matches = re.match(r'^Delete when resource still referred: (\[[^]]*\])($| \(HTTP 409\)$)', e.message)
if matches:
raise BackRefsExists(
resources=list(hrefs_list_to_resources(matches.group(1))))
# contrail 2.21
matches = re.match(r'^Children (.*) still exist($| \(HTTP 409\)$)', e.message)
if matches:
raise ChildrenExists(
resources=list(hrefs_to_resources(matches.group(1))))
matches = re.match(r'^Back-References from (.*) still exist($| \(HTTP 409\)$)', e.message)
if matches:
raise BackRefsExists(
resources=list(hrefs_to_resources(matches.group(1))))
# contrail 5.1
matches = re.match(r'^Cannot modify system resource (.*) .*\(([a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12})\)($| \(HTTP 409\)$)', e.message)
if matches:
raise IsSystemResource(resources=[Resource(matches.group(1), uuid=matches.group(2))])
raise
return wrapper
class LinkType(object):
BACK_REF = "back_refs"
REF = "refs"
CHILDREN = "children"
class LinkedResources(object):
"""Intermediate class to manage linked resources of a resource.
Given the LinkType the class allows iteration of linked resources
and provide access to linked resources types via direct attributes.
Linked types are validated be the resource schema.
>>> vmi = Resource('virtual-machine-interface',
uuid='61ceff9d-9993-4d30-a337-abb0102f9f92')
>>> vmi.fetch()
>>> vmi.refs
LinkedResources(refs)
>>> vmi.refs.virtual_network
[Resource(/virtual-network/6dee5930-5ea3-4fa9-adfd-6d5b68c360b7)]
>>> list(vmi.refs)
[Resource(/security-group/3304f964-f75c-4a3b-9f91-5c89090346bf),
Resource(/virtual-network/6dee5930-5ea3-4fa9-adfd-6d5b68c360b7),
Resource(/routing-instance/e055dd4f-d7d9-4979-95ac-44a5c6269278)]
"""
def __init__(self, link_type, resource):
self.link_type = link_type
self.resource = resource
self.linked_types = getattr(self.resource.schema, self.link_type)
def _type_to_attr(self, type):
type = type.replace('-', '_')
if self.link_type == LinkType.CHILDREN:
return type + 's'
else:
return type + '_' + self.link_type
def _attr_to_type(self, attr):
if self.link_type == LinkType.CHILDREN:
attr = attr[:-1]
else:
attr = attr.split('_' + self.link_type)[0]
return attr.replace('_', '-')
def __getattr__(self, type):
if type.replace('_', '-') not in self.linked_types:
return []
return self.resource.get(self._type_to_attr(type), [])
def __iter__(self):
return itertools.chain(*[getattr(self, res_type)
for res_type in self.linked_types])
def __getitem__(self, key):
return list(self.__iter__())[key]
def __dir__(self):
return sorted(set(dir(type(self)) + list(self.__dict__) +
[t.replace('-', '_') for t in self.linked_types]))
def encode(self, data, recursive=1):
for attr, _ in list(data.items()):
type = self._attr_to_type(attr)
if type in self.linked_types:
for idx, res in enumerate(data[attr]):
data[attr][idx] = Resource(type,
fetch=recursive - 1 > 0,
recursive=recursive - 1,
**res)
if self.link_type == LinkType.CHILDREN:
data[attr] = Collection(type, parent_uuid=self.resource.uuid,
data=data[attr])
elif self.link_type == LinkType.BACK_REF:
data[attr] = Collection(type, back_refs_uuid=self.resource.uuid,
data=data[attr])
return data
def __repr__(self):
return '%s' % list(self.__iter__())
class ResourceEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, FQName):
return obj._data
if isinstance(obj, Resource):
return obj.data
if isinstance(obj, Collection):
return obj.data
return super(ResourceEncoder, self).default(obj)
class ResourceBase(Observable):
def __init__(self, session=None):
self._session = session
def __repr__(self):
return '%s(%s)' % (self.__class__.__name__, self.path)
@property
def session(self):
if self._session is not None:
return self._session
return Context().session
@property
def uuid(self):
return ''
@property
def fq_name(self):
return FQName()
@property
def path(self):
"""Return Path of the resource
:rtype: Path
"""
return Path("/") / self.type / self.uuid
@property
def href(self):
"""Return URL of the resource
:rtype: str
"""
url = self.session.base_url + str(self.path)
if self.path.is_collection and not self.path.is_root:
return url + 's'
return url
[docs]class Collection(ResourceBase, UserList):
"""Class for interacting with an API collection
>>> from contrail_api_cli.resource import Collection
>>> c = Collection('virtual-network', fetch=True)
>>> # iterate over the resources
>>> for r in c:
>>> print(r.path)
>>> # filter support
>>> c.filter("router_external", False)
>>> c.fetch()
>>> assert all([r.get('router_external') for r in c]) == False
:param type: name of the collection
:type type: str
:param fetch: immediately fetch collection from the server
:type fetch: bool
:param recursive: level of recursion
:type recursive: int
:param fields: list of field names to fetch
:type fields: [str]
:param filters: list of filters
:type filters: [(name, value), ...]
:param parent_uuid: filter by parent_uuid
:type parent_uuid: v4UUID str or list of v4UUID str
:param back_ref_uuid: filter by back_ref_uuid
:type back_ref_uuid: v4UUID str or list of v4UUID str
:param data: initial resources of the collection
:type data: [Resource]
"""
def __init__(self, type, fetch=False, recursive=1,
fields=None, detail=None, filters=None,
parent_uuid=None, back_refs_uuid=None,
data=None, session=None):
super(Collection, self).__init__(session=session)
UserList.__init__(self, initlist=data)
self.type = type
self.fields = fields or []
self.filters = filters or []
self.parent_uuid = list(self._sanitize_uuid(parent_uuid))
self.back_refs_uuid = list(self._sanitize_uuid(back_refs_uuid))
self.detail = detail
if fetch:
self.fetch(recursive=recursive)
self.emit('created', self)
@http_error_handler
def __len__(self):
"""Return the number of items of the collection
:rtype: int
"""
if not self.data:
params = self._format_fetch_params()
res = self.session.get_json(self.href, count=True, **params)
try:
return res[self._contrail_name]['count']
except KeyError:
return 0
return super(Collection, self).__len__()
def __hash__(self):
return hash(self.type)
@property
def _contrail_name(self):
if self.type:
return self.type + 's'
return self.type
def _sanitize_uuid(self, uuid):
if uuid is None:
raise StopIteration
if isinstance(uuid, string_types):
uuid = [uuid]
for u in uuid:
try:
UUID(u, version=4)
except ValueError:
continue
yield u
[docs] def filter(self, field_name, field_value):
"""Add permanent filter on the collection
:param field_name: name of the field to filter on
:type field_name: str
:param field_value: value to filter on
:rtype: Collection
"""
self.filters.append((field_name, field_value))
return self
def _format_fetch_params(self, fields=[], detail=False, filters=[],
parent_uuid=None, back_refs_uuid=None):
params = {}
detail = detail or self.detail
fields_str = ",".join(self._fetch_fields(fields))
filters_str = ",".join(['%s==%s' % (f, json.dumps(v))
for f, v in self._fetch_filters(filters)])
parent_uuid_str = ",".join(self._fetch_parent_uuid(parent_uuid))
back_refs_uuid_str = ",".join(self._fetch_back_refs_uuid(back_refs_uuid))
if detail is True:
params['detail'] = detail
elif fields_str:
params['fields'] = fields_str
if filters_str:
params['filters'] = filters_str
if parent_uuid_str:
params['parent_id'] = parent_uuid_str
if back_refs_uuid_str:
params['back_ref_id'] = back_refs_uuid_str
return params
def _fetch_parent_uuid(self, parent_uuid=None):
return self.parent_uuid + list(self._sanitize_uuid(parent_uuid))
def _fetch_back_refs_uuid(self, back_refs_uuid=None):
return self.back_refs_uuid + list(self._sanitize_uuid(back_refs_uuid))
def _fetch_filters(self, filters=None):
return self.filters + (filters or [])
def _fetch_fields(self, fields=None):
return self.fields + (fields or [])
[docs] @http_error_handler
def fetch(self, recursive=1, fields=None, detail=None,
filters=None, parent_uuid=None, back_refs_uuid=None):
"""
Fetch collection from API server
:param recursive: level of recursion
:type recursive: int
:param fields: fetch only listed fields.
contrail 3.0 required
:type fields: [str]
:param detail: fetch all fields
:type detail: bool
:param filters: list of filters
:type filters: [(name, value), ...]
:param parent_uuid: filter by parent_uuid
:type parent_uuid: v4UUID str or list of v4UUID str
:param back_refs_uuid: filter by back_refs_uuid
:type back_refs_uuid: v4UUID str or list of v4UUID str
:rtype: Collection
"""
params = self._format_fetch_params(fields=fields, detail=detail, filters=filters,
parent_uuid=parent_uuid, back_refs_uuid=back_refs_uuid)
data = self.session.get_json(self.href, **params)
if not self.type:
self.data = [Collection(col["link"]["name"],
fetch=recursive - 1 > 0,
recursive=recursive - 1,
fields=self._fetch_fields(fields),
detail=detail or self.detail,
filters=self._fetch_filters(filters),
parent_uuid=self._fetch_parent_uuid(parent_uuid),
back_refs_uuid=self._fetch_back_refs_uuid(back_refs_uuid))
for col in data['links']
if col["link"]["rel"] == "collection"]
else:
# when detail=False, res == {resource_attrs}
# when detail=True, res == {'type': {resource_attrs}}
self.data = [Resource(self.type,
fetch=recursive - 1 > 0,
recursive=recursive - 1,
**res.get(self.type, res))
for res_type, res_list in data.items()
for res in res_list]
return self
class RootCollection(Collection):
def __init__(self, **kwargs):
return super(RootCollection, self).__init__('', **kwargs)
[docs]class Resource(ResourceBase, UserDict):
"""Class for interacting with an API resource
>>> from contrail_api_cli.resource import Resource
>>> r = Resource('virtual-network',
uuid='4c45e89b-7780-4b78-8508-314fe04a7cbd',
fetch=True)
>>> r['display_name'] = 'foo'
>>> r.save()
>>> p = Resource('project', fq_name='default-domain:admin')
>>> r = Resource('virtual-network', fq_name='default-domain:admin:net1',
parent=p)
>>> r.save()
:param type: type of the resource
:type type: str
:param fetch: immediately fetch resource from the server
:type fetch: bool
:param uuid: uuid of the resource
:type uuid: v4UUID str
:param fq_name: fq name of the resource
:type fq_name: str (domain:project:identifier)
or list ['domain', 'project', 'identifier']
:param check: check that the resource exists
:type check: bool
:param parent: parent resource
:type parent: Resource
:param recursive: level of recursion
:type recursive: int
:raises ResourceNotFound: bad uuid or fq_name is given
:raises HttpError: when save(), fetch() or delete() fail
.. note::
Either fq_name or uuid must be provided.
"""
def __init__(self, type, fetch=False, check=False,
parent=None, recursive=1, session=None, **kwargs):
assert('fq_name' in kwargs or 'uuid' in kwargs or 'to' in kwargs)
super(Resource, self).__init__(session=session)
self.type = type
UserDict.__init__(self, kwargs)
self.from_dict(self.data)
if parent:
self.parent = parent
if check:
self.check()
if fetch:
self.fetch(recursive=recursive)
self.properties = {prop.key: prop for prop in self.schema.properties}
self.emit('created', self)
def __getattr__(self, attr):
if attr in self.properties:
return self.get(attr, self.properties[attr].default)
msg = "'{0}' object has no attribute '{1}'"
raise AttributeError(msg.format(type(self).__name__, attr))
def __dir__(self):
return sorted(set(dir(type(self)) + list(self.__dict__) +
self.properties.keys()))
def __eq__(self, other):
if not isinstance(other, Resource) or not self.type == other.type:
return False
if self.uuid != other.uuid:
return False
if self.fq_name != other.fq_name:
return False
return True
def __hash__(self):
return hash((self.type, self.uuid, str(self.fq_name)))
@property
def schema(self):
return Context().schema.resource(self.type)
[docs] def check(self):
"""Check that the resource exists.
:raises ResourceNotFound: if the resource doesn't exists
"""
if self.fq_name:
self['uuid'] = self._check_fq_name(self.fq_name)
elif self.uuid:
self['fq_name'] = self._check_uuid(self.uuid)
return True
@http_error_handler
def _check_uuid(self, uuid):
return self.session.id_to_fqname(uuid, type=self.type)['fq_name']
@http_error_handler
def _check_fq_name(self, fq_name):
return self.session.fqname_to_id(fq_name, self.type)
@property
def exists(self):
"""Returns True if the resource exists on the API server,
or returns False.
:rtype: bool
"""
try:
self.check()
except ResourceNotFound:
return False
return True
@property
def uuid(self):
"""Return UUID of the resource
:rtype: str
"""
return self.get('uuid', super(Resource, self).uuid)
@property
def fq_name(self):
"""Return FQDN of the resource
:rtype: FQName
"""
return self.get('fq_name', self.get('to', super(Resource, self).fq_name))
@property
def parent(self):
"""Return parent resource
:rtype: Resource
:raises ResourceNotFound: parent resource doesn't exists
:raises ResourceMissing: parent resource is not defined
"""
try:
return Resource(self['parent_type'], uuid=self['parent_uuid'], check=True)
except KeyError:
raise ResourceMissing('%s has no parent resource' % self)
@parent.setter
def parent(self, resource):
"""Set parent resource
:param resource: parent resource
:type resource: Resource
:raises ResourceNotFound: resource not found on the API
"""
resource.check()
self['parent_type'] = resource.type
self['parent_uuid'] = resource.uuid
@property
def created(self):
"""Return creation date
:rtype: datetime
:raises ResourceNotFound: resource not found on the API
"""
if 'id_perms' not in self:
self.fetch()
created = self['id_perms']['created']
return datetime.strptime(created, '%Y-%m-%dT%H:%M:%S.%f')
[docs] @http_error_handler
def save(self):
"""Save the resource to the API server
If the resource doesn't have a uuid the resource will be created.
If uuid is present the resource is updated.
:rtype: Resource
"""
if self.path.is_collection:
self.session.post_json(self.href,
{self.type: dict(self.data)},
cls=ResourceEncoder)
else:
self.session.put_json(self.href,
{self.type: dict(self.data)},
cls=ResourceEncoder)
return self.fetch(exclude_children=True, exclude_back_refs=True)
[docs] @http_error_handler
def delete(self):
"""Delete resource from the API server
"""
res = self.session.delete(self.href)
self.emit('deleted', self)
return res
[docs] @http_error_handler
def fetch(self, recursive=1, exclude_children=False, exclude_back_refs=False):
"""Fetch resource from the API server
:param recursive: level of recursion for fetching resources
:type recursive: int
:param exclude_children: don't get children references
:type exclude_children: bool
:param exclude_back_refs: don't get back_refs references
:type exclude_back_refs: bool
:rtype: Resource
"""
if not self.path.is_resource and not self.path.is_uuid:
self.check()
params = {}
# even if the param is False the API will exclude resources
if exclude_children:
params['exclude_children'] = True
if exclude_back_refs:
params['exclude_back_refs'] = True
data = self.session.get_json(self.href, **params)[self.type]
self.from_dict(data)
return self
[docs] def from_dict(self, data, recursive=1):
"""Populate the resource from a python dict
:param recursive: level of recursion for fetching resources
:type recursive: int
"""
# Find other linked resources
data = self._encode_resource(data, recursive=recursive)
self.data = data
def _encode_resource(self, data, recursive=1):
for attr in ('fq_name', 'to'):
if attr in data:
data[attr] = FQName(data[attr])
data = self.refs.encode(data, recursive)
data = self.back_refs.encode(data, recursive)
data = self.children.encode(data, recursive)
return data
@property
def refs(self):
"""Return refs resources of the resource
:rtype: LinkedResources
"""
return LinkedResources(LinkType.REF, self)
@property
def back_refs(self):
"""Return back_refs resources of the resource
:rtype: LinkedResources
"""
return LinkedResources(LinkType.BACK_REF, self)
@property
def children(self):
"""Return children resources of the resource
:rtype: LinkedResources
"""
return LinkedResources(LinkType.CHILDREN, self)
[docs] def remove_ref(self, ref):
"""Remove reference from self to ref
>>> iip = Resource('instance-ip',
uuid='30213cf9-4b03-4afc-b8f9-c9971a216978',
fetch=True)
>>> for vmi in iip['virtual_machine_interface_refs']:
iip.remove_ref(vmi)
>>> iip['virtual_machine_interface_refs']
KeyError: u'virtual_machine_interface_refs'
:param ref: reference to remove
:type ref: Resource
:rtype: Resource
"""
self.session.remove_ref(self, ref)
return self.fetch()
[docs] def remove_back_ref(self, back_ref):
"""Remove reference from back_ref to self
:param back_ref: back_ref to remove
:type back_ref: Resource
:rtype: Resource
"""
back_ref.remove_ref(self)
return self.fetch()
[docs] def set_ref(self, ref, attr=None):
"""Set reference to resource
Can be used to set references on a resource
that is not already created.
:param ref: reference to add
:type ref: Resource
:rtype: Resource
"""
ref_attr = '%s_refs' % ref.type.replace('-', '_')
ref = {
'to': ref.fq_name,
'uuid': ref.uuid,
}
if ref_attr in self:
self[ref_attr].append(ref)
else:
self[ref_attr] = [ref]
return self
[docs] def add_ref(self, ref, attr=None):
"""Add reference to resource
:param ref: reference to add
:type ref: Resource
:rtype: Resource
"""
self.session.add_ref(self, ref, attr)
return self.fetch()
[docs] def add_back_ref(self, back_ref, attr=None):
"""Add reference from back_ref to self
:param back_ref: back_ref to add
:type back_ref: Resource
:rtype: Resource
"""
back_ref.add_ref(self, attr)
return self.fetch()
[docs] def json(self):
"""Return JSON representation of the resource
"""
return to_json(self.data, cls=ResourceEncoder)
class Actions:
STORE = 'STORE'
DELETE = 'DELETE'
class ResourceCache(object):
"""Resource cache of discovered resources.
"""
def __init__(self):
self.cache = {
'resources': datrie.Trie(string.printable),
'collections': datrie.Trie(string.printable)
}
Resource.register('created', self._add_item)
Resource.register('deleted', self._del_item)
Collection.register('created', self._add_item)
Collection.register('deleted', self._del_item)
def search_resources(self, strings, limit=None):
return self._search(self.cache['resources'], strings, limit=limit)
def search_collections(self, strings, limit=None):
return self._search(self.cache['collections'], strings, limit=limit)
def search(self, strings, limit=None):
return self.search_collections(strings, limit=limit) + \
self.search_resources(strings, limit=limit)
def _search(self, trie, strings, limit=None):
"""Search in cache
:param strings: list of strings to get from the cache
:type strings: str list
:param limit: limit search results
:type limit: int
:rtype: [Resource | Collection]
"""
results = [trie.has_keys_with_prefix(s) for s in strings]
if not any(results):
return []
for result, s in zip(results, strings):
if result is True:
return trie.values(s)[:limit]
def _get_trie_for_item(self, item):
if isinstance(item, Collection):
trie = self.cache['collections']
elif isinstance(item, Resource):
trie = self.cache['resources']
else:
raise RuntimeError('Invalid item')
return trie
def _get_keys(self, item):
if item.fq_name:
yield '/%s/%s' % (item.type, item.fq_name)
yield text_type(item.path)
def _add_item(self, item):
trie = self._get_trie_for_item(item)
for key in self._get_keys(item):
trie[key] = item
def _del_item(self, item):
trie = self._get_trie_for_item(item)
for key in self._get_keys(item):
try:
del trie[key]
except KeyError:
pass
def get_completions(self, word_before_cursor, context, option=None):
cache_type, type, attr = option.complete.split(':')
if attr == 'path':
path = context.current_path / word_before_cursor
if type and path.base != type:
raise StopIteration
else:
path = Path('/')
if type:
path = path / type
path = path / word_before_cursor
logger.debug('Search for %s' % path)
results = getattr(self, 'search_' + cache_type)([text_type(path)])
seen = set()
for r in results:
if (r.type, r.uuid) in seen:
continue
seen.add((r.type, r.uuid))
if attr == 'path':
value = text_type(r.path.relative_to(context.current_path))
else:
value = text_type(getattr(r, attr))
if attr == 'fq_name':
meta = r.uuid
else:
meta = text_type(r.fq_name)
if value:
yield Completion(value,
-len(word_before_cursor),
display_meta=meta)