# -*- coding: utf-8 -*-
'''API Model Classes
These resource models are derrived from collections.MutableMapping and, thus,
act like dicts and can instantiate with raw resource output from API as well as
simplifying by providing site_id and (usually) the natural key (cidr, hostname,
etc).
Example
-------
>>> from pynsot.models import Network, Device, Interface
>>> from pynsot.client import get_api_client
>>> client = get_api_client()
>>> net = Network(raw=client.networks.get()[-1])
>>>
>>> # Or also...
>>> # >>> net = Network(client=client, site_id=1, cidr='8.8.8.0/24')
>>>
>>> net.exists()
>>> True
>>>
>>> net.existing_resource()
{u'attributes': {},
u'id': 81,
u'ip_version': u'4',
u'is_ip': False,
u'network_address': u'254.0.0.0',
u'parent_id': 1,
u'prefix_length': 24,
u'site_id': 1,
u'state': u'allocated'}
>>>
>>> net.purge()
True
>>>
>>> net.exists()
False
>>>
>>> net.ensure()
True
>>>
>>> net.exists()
True
>>>
>>> net['site_id']
2
>>>
>>> net['site_id'] = 4
>>>
>>> net.exists()
False
>>>
>>> net['site_id'] = 2
>>>
>>> net.exists()
True
'''
from __future__ import unicode_literals
import logging
import collections
from abc import abstractproperty, abstractmethod, ABCMeta
from netaddr import IPNetwork
from pynsot.util import get_result
from pynsot.client import get_api_client
[docs]class Resource(collections.MutableMapping):
'''Base API Abstraction Models Class
Instances of an API abstraction model represent a single NSoT resource and
provide methods for managing the state of it upstream. They can be
instantiated by the raw returned object from NSoT API or more simply by a
few descriptive kwargs.
Resource is a subclass of :class:``collections.MutableMapping`` which makes
it act as a dictionary would. The mapping represents the payload that would
be accepted by NSoT and can be manipulated as desired like a normal dict.
Subclassing:
Subclasses must adhere to a simple contract:
* Overload the abstractmethods and abstractproperties this class
uses
* If custom arguments are needed, overload ``self.postinit``.
Just make sure to call ``self.init_payload()`` at the end. All
kwargs that aren't handled by ``self.__init__`` are passed here
:param site_id: Site ID this reseource belongs to. Required unless ``raw``
is supplied.
:type site_id: int
:param attributes: Attributes to add to resource. If supplying ``raw``, add
these after the instantiation like:
>>> obj = Device(raw=RAW_API_DICT)
>>> obj['attributes'] = {}
:type attributes: dict
:param client: Pynsot client for API interactions. Will be lazily loaded if
not provided, but might be cheaper to supply it up front.
:type client: pynsot.client.BaseClient
:param raw: Raw NSoT resource object. What would be returned from a GET,
POST, PUT, or PATCH operation for a single resource. Gets mapped
directly to payload
:type raw: dict
'''
__metaclass__ = ABCMeta
def __init__(
self,
site_id=None,
client=None,
raw=None,
attributes=None,
**kwargs
):
if raw is None:
raw = {}
if attributes is None:
attributes = {}
self.logger = logging.getLogger(__name__)
self.errors = []
self.last_error = None
# Placeholder for .existing_resource() state
self._existing_resource = {}
self._payload = {}
# Parameter validations
# Site ID is required but can come from either kwarg or raw resource
# dict, latter being preferred.
if raw.get('site_id'):
site_id = raw['site_id']
elif site_id:
pass # Already set
else:
raise TypeError(
'Resource requires site_id via param or ``raw`` key'
)
self._site_id = site_id
self.client = client
self.raw = raw
self.attributes = attributes
if not self.raw:
self.postinit(**kwargs)
else:
# This is done here because subclases do this in their postinit().
# If raw is passed, there's no reason to go through the postinit
# process which takes things like network_address, hostname, etc.
#
# Every subclasses init_payload method has a condition for 'if raw
# set raw' basically.
self.init_payload()
[docs] def postinit(self, **kwargs):
'''Overloadable method for post __init__
Use this for things that need to happen post-init, including
subclass-specific argument handling.
This method is called at the very end of __init__ unless ``raw`` is
given.
:params kwargs: All unhandled kwargs from __init__ are passed here
:type kwargs: dict
'''
# If not being overloaded by subclass, still need to call this required
# method if ``raw`` isn't provided
self.init_payload()
pass
[docs] def ensure_client(self, **kwargs):
'''Ensure that object has a client object
Client may be passed during __init__ as a kwarg, but call this before
doing any client work to ensure
:param kwargs: These will be passed to get_api_client.
:type kwargs: dict
'''
if self.client:
return
else:
self.client = get_api_client(**kwargs)
@abstractproperty
def identifier(self):
'''Human-friendly string to represent the resource
Used in log messages and magic methods for comparison. Examples here
would be CIDR, hostname, etc
:rtype: str
'''
pass
@property
def resource(self):
'''Pynsot client for resource type
:rtype: pynsot.client.BaseClient
'''
self.ensure_client()
return getattr(self.client, self.resource_name)
@abstractproperty
def resource_name(self):
'''Name of resource
Must be plural
:rtype: str
'''
pass
[docs] @abstractmethod
def init_payload(self):
'''
Initializes the payload dictionary using resource specific data
'''
pass
@property
def payload(self):
'''Represents exact payload sent to NSoT server
:returns: _payload
:rtype: dict
'''
return self._payload
@payload.setter
def payload(self, value):
'''Setter for payload'''
self._payload = value
def __iter__(self):
'''Iterate through payload keys'''
return iter(self.payload)
def __getitem__(self, key):
'''Get item from payload'''
return self.payload[key]
def __setitem__(self, key, value):
'''Set item in payload
Cache is cleared here since the current resource has changed
'''
self.clear_cache()
self.payload[key] = value
def __delitem__(self, key):
'''Delete item from payload
Cache is cleared here since the current resource has changed
'''
self.clear_cache()
del self.payload[key]
def __len__(self):
return len(self._payload)
def __repr__(self):
'''Human-friendly representation'''
title = self.resource_name[:-1].title()
return '<%s: %s>' % (title, str(self))
def __str__(self):
return str(self.identifier)
def __eq__(self, other):
'''Using ``identifier`` and ``site_id``, compare to another resource'''
try:
x = '%s:%s' % (self.identifier, self._site_id)
y = '%s:%s' % (other.identifier, other._site_id)
return x == y
except:
raise TypeError('Other object is not a Resource type')
[docs] def log_error(self, error):
'''Log and append errors to object
This does a check to see if response object available from HTTP
request. If not, that's OK too and it'll just append the raw error.
'''
if hasattr(error, 'response') and hasattr(error.response, 'json'):
try:
meta = error.response.json()
except:
meta = error.response
else:
meta = error
self.logger.debug(
'[%s] %s' % (self.identifier, meta),
exc_info=True,
)
self.logger.warning(
'[%s] %s' % (self.identifier, meta),
)
self.errors.append(meta)
self.last_error = meta
[docs] def existing_resource(self):
'''Returns upstream resource
If nothing exists, empty dict is returned. The result of this is cached
in a property (_existing_resource) for cheaper re-checks. This can be
cleared via ``.clear_cache()``
:rtype: dict
'''
self.ensure_client()
if self._existing_resource:
return self._existing_resource
else:
cur = dict(self)
cur.pop('attributes', None)
# We pop attributes because the query fails leaving them in
try:
# Site client of resource type because NSoT doesn't support
# passing site_id as a query parameter
site = getattr(
self.client.sites(self['site_id']),
self.resource_name,
)
lookup = get_result(site.get(**cur))
except Exception as e:
self.log_error(e)
# There might be a better way to do this. If the NSoT server is
# unreachable or otherwise the lookup fails it'll log properly
# and return as if nothing exists. If the resource actually
# exists but due to reachability it couldn't confirm some
# action may be unnecessarily took user-side.
#
# Honestly, though, I think this is a decent trade-off
self._existing_resource = {}
return self._existing_resource
existing = len(lookup) > 0
if existing:
# This is where state will be kept for this
self._existing_resource = lookup[0]
return self._existing_resource
else:
self._existing_resource = {}
return self._existing_resource
[docs] def clear_cache(self):
'''Clears state of certain properties
This is ideally done during a write operation against the API, such as
``.purge()`` or ``.ensure()``. Helps prevent representing out-of-date
information
'''
self._existing_resource = {}
[docs] def exists(self):
'''Does the current resource exist?
:rtype: bool
'''
return bool(self.existing_resource())
[docs] def ensure(self):
'''Ensure object in current state exists in NSoT
By site, make sure resource exists. True if it is or was able to get to
the desired state, False if not and logged in ``last_error``.
Having this operation be done individually for each resource
has some pros and cons. Generally as long as the amount of items
is small enough, it's not a huge difference but it is an extra
HTTP request.
Sending in bulk halts at the first error and fails the following
so it requires more handling.
Cache is cleared first thing and before return.
:rtype: bool
'''
self.clear_cache()
to_ensure = dict(self)
try:
# PATCH instead of POST
if self.exists() and self.resource_name != 'interfaces':
self.logger.debug('[%s] Patching', self.identifier)
to_ensure['id'] = self.existing_resource()['id']
self.resource.patch([to_ensure])
self.logger.info('[%s] has been patched!', self.identifier)
self.clear_cache()
return True
else: # POST for initially creating
self.logger.debug('[%s] Creating', self.identifier)
self.resource.post([to_ensure])
self.logger.info('[%s] has been created!', self.identifier)
return True
except Exception as e:
self.log_error(e)
self.clear_cache()
return False
[docs] def purge(self):
'''Ensure resource doesn't exist upstream
By site, make sure resource is deleted. True if it is or was able to
get to the desired state, False if not and logged in ``last_error``.
Cache is cleared first thing and before return.
:rtype: bool
'''
self.clear_cache()
try:
if self.exists():
self.logger.debug('[%s] Deleting', self.identifier)
id_ = self.existing_resource()['id']
self.resource(id_).delete()
self.logger.info('[%s] has been deleted!', self.identifier)
self.clear_cache()
return True
else:
# We should return True to show the state is already how we
# want it to be. False should be for unable to ensure resource
# is gone
return True
except Exception as e:
self.log_error(e)
self.clear_cache()
return False
[docs]class Network(Resource):
'''Network API Abstraction Model
Subclass of Resource.
>>> n = Network(cidr='8.8.8.0/24', site_id=1)
>>> n.exists()
False
>>> n.ensure()
True
>>> n.exists()
True
>>> n.existing_resource()
{u'attributes': {},
u'id': 31,
u'ip_version': u'4',
u'is_ip': True,
u'network_address': u'8.8.8.0',
u'parent_id': 1,
u'prefix_length': 24,
u'site_id': 1,
u'state': u'assigned'}
>>>
>>> n.purge()
True
>>> n.exists()
False
>>> n.closest_parent()
<Network: 8.8.0.0/16>
:param cidr: CIDR for network
'''
def postinit(self, cidr=None):
if not any([cidr, self.raw]):
raise TypeError('Networks require a cidr')
net = IPNetwork(cidr)
ver = net.version
self.network_address = str(net.network)
self.prefix_length = int(net.prefixlen)
self.is_host = ver == 4 and net.prefixlen == 32 or net.prefixlen == 128
self.init_payload()
@property
def identifier(self):
return '%s/%d' % (self['network_address'], self['prefix_length'])
@property
def resource_name(self):
return 'networks'
[docs] def init_payload(self):
'''This will init the payload property'''
if self.raw:
self.payload = self.raw
return
self.payload = {
'is_ip': self.is_host,
'network_address': self.network_address,
'prefix_length': self.prefix_length,
'state': self.is_host and 'assigned' or 'allocated',
'site_id': self._site_id,
'attributes': self.attributes,
}
[docs] def closest_parent(self):
'''Returns resource object of the closest parent network
Empty dictionary if no parent network
:returns: Parent resource
:rtype: pynsot.models.Network or dict
'''
self.ensure_client()
site = getattr(self.client.sites(self['site_id']), self.resource_name)
cidr = '%s/%s' % (self['network_address'], self['prefix_length'])
try:
lookup = get_result(site(cidr).closest_parent.get())
return Network(raw=lookup)
except Exception as e:
self.log_error(e)
return {}
def __len__(self):
return self['prefix_length']
[docs]class Device(Resource):
'''Device Resource
Subclass of Resource.
>>> dev = Device(hostname='router1-nyc', site_id=1)
>>> dev.exists()
False
>>>
>>> dev.ensure()
True
>>>
>>> dev.existing_resource()
{u'attributes': {}, u'hostname': u'router1-nyc', u'id': 1, u'site_id': 1}
>>> dev.purge()
True
>>>
>>> dev.exists()
False
:param hostname: Device hostname
'''
def postinit(self, hostname=None):
if not any([hostname, self.raw]):
raise TypeError('Devices require a hostname')
self.hostname = hostname
self.init_payload()
@property
def identifier(self):
return self.hostname
@property
def resource_name(self):
return 'devices'
def init_payload(self):
if self.raw:
self.payload = self.raw
return
self.payload = {
'hostname': self.hostname,
'site_id': self._site_id,
'attributes': self.attributes,
}
[docs]class Interface(Resource):
'''Interface Resource
Subclass of Resource
:param addresses: Addresses on interface
:type addresses: list
:param description: Interface description
:param device: Required, device interface is on. TODO: broken as currently
implemented but will soon reflect the following type
:type device: pynsot.models.Device
:param type: Interface type as described by SNMP IF-TYPE's
:type type: int
:param mac_address: MAC of interface
:param name: Required, name of interface
:param parent_id: ID of parent interface
:type parent_id: int
:param speed: Speed of interface
:type speed: int
'''
def postinit(self, **kwargs):
self.addresses = kwargs.get('addresses') or []
self.description = kwargs.get('description') or ''
self.device = kwargs.get('device') or 0
self._original_device = kwargs.get('device') or None
self.type = kwargs.get('type') or 6
self.mac_address = kwargs.get('mac_address') or '00:00:00:00:00:00'
self.name = kwargs.get('name') or None
self.parent_id = kwargs.get('parent_id') or None
self.speed = kwargs.get('speed') or 1000
if not all([self.name, self.device]) or self.raw:
raise TypeError('Interfaces require both a name and device!')
self.attempt_device()
self.init_payload()
[docs] def attempt_device(self):
'''Attempt to set ``device`` attribute to its ID if hostname was given
If an ID was provided during init, this happens in init. If a hostname
was provided, we need to take care of a couple things.
1. Attempt to fetch ID for existing device by the hostname. If this
works, use this ID
2. Should #1 fail, set device id to 0 to signify this device doesn't
exist yet, hoping this method will be called again when it's time to
create. The thought here is if a user is instantiating lots of
resources at the same time, there's a chance the device this relates
to is going to be created before this one actually gets called upon.
0 is used instead of a more descriptive message because should the
device still not exist when executing this, at least a device
doesn't exist error would be returned instead of expecting int not
str.
'''
rerun = isinstance(self._original_device, str) and self.device == 0
first_run = isinstance(self.device, str)
# If equal to 0, means it had failed before
if rerun or first_run:
d = Device(
client=self.client,
site_id=self._site_id,
hostname=self._original_device,
)
if d.exists():
self.device = d.existing_resource()['id']
return True
else:
self.device = 0
return False
@property
def identifier(self):
return '%s on %s' % (self.name, self.device)
@property
def resource_name(self):
return 'interfaces'
def init_payload(self):
if self.raw:
self.payload = self.raw
return
# TODO: This currently will only work on init and not later since
# init_payload is only called once. This is left over from when it was
# just payload
#
# attempt_device should instead set self.payload['device'] directly
self.attempt_device()
self.payload = {
'addresses': self.addresses,
'description': self.description,
'device': self.device,
'type': self.type,
'mac_address': self.mac_address,
'name': self.name,
'parent_id': self.parent_id,
'speed': self.speed,
'attributes': self.attributes,
'site_id': self._site_id,
}