#
# Copyright (c) 2014, Arista Networks, Inc.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met:
#
# Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
#
# Neither the name of Arista Networks nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL ARISTA NETWORKS
# BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
# OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN
# IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
# pylint: disable=W0622,W0402,W0613,W0142,R0201, E1103
#
import logging
import os
import routes
import urlparse
from string import Template
from webob.static import FileApp
import ztpserver.config
from ztpserver.constants import HTTP_STATUS_NOT_FOUND, HTTP_STATUS_CREATED
from ztpserver.constants import HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_CONFLICT
from ztpserver.constants import HTTP_STATUS_INTERNAL_SERVER_ERROR
from ztpserver.constants import CONTENT_TYPE_JSON, CONTENT_TYPE_PYTHON
from ztpserver.constants import CONTENT_TYPE_YAML, CONTENT_TYPE_OTHER
from ztpserver.repository import create_repository
from ztpserver.repository import FileObjectNotFound, FileObjectError
from ztpserver.resources import ResourcePoolError
from ztpserver.serializers import SerializerError
from ztpserver.topology import create_node, load_pattern
from ztpserver.topology import load_neighbordb, resources
from ztpserver.topology import replace_config_action
from ztpserver.wsgiapp import WSGIController, WSGIRouter
DEFINITION_FN = 'definition'
STARTUP_CONFIG_FN = 'startup-config'
PATTERN_FN = 'pattern'
NODE_FN = '.node'
ATTRIBUTES_FN = 'attributes'
BOOTSTRAP_CONF = 'bootstrap.conf'
log = logging.getLogger(__name__) # pylint: disable=C0103
[docs]class ValidationError(Exception):
''' Base exception class for :py:class:`Pattern` '''
pass
[docs]class BaseController(WSGIController):
FOLDER = None
def __init__(self, **kwargs):
data_root = ztpserver.config.runtime.default.data_root
self.data_root = data_root
self.repository = create_repository(data_root)
super(BaseController, self).__init__()
[docs] def expand(self, *args, **kwargs):
''' Returns an expanded file path relative to data_root '''
file_path = os.path.join(*args)
folder = kwargs.get('folder', self.FOLDER)
return os.path.join(folder, file_path)
[docs] def http_bad_request(self, *args, **kwargs):
''' Returns HTTP 400 Bad Request '''
return dict(body='', content_type='text/html',
status=HTTP_STATUS_BAD_REQUEST)
[docs] def http_not_found(self, *args, **kwargs):
''' Returns HTTP 404 Not Found '''
return dict(body='', content_type='text/html',
status=HTTP_STATUS_NOT_FOUND)
[docs] def http_internal_server_error(self, *args, **kwargs):
''' Returns HTTP 500 Internal server error '''
return dict(body='', content_type='text/html',
status=HTTP_STATUS_INTERNAL_SERVER_ERROR)
[docs]class FilesController(BaseController):
FOLDER = 'files'
def __repr__(self):
return 'FilesController(folder=%s)' % self.FOLDER
[docs] def show(self, request, resource, **kwargs):
''' Handles GET /files/{resource} '''
log.debug('%s\nResource: %s\n' % (request, resource))
try:
urlvars = request.urlvars
if urlvars.get('format') is not None:
resource += '.%s' % urlvars.get('format')
file_path = self.expand(resource)
filename = self.repository.get_file(file_path).name
return FileApp(filename, content_type=CONTENT_TYPE_OTHER)
except FileObjectNotFound:
log.error('File %s not found' % resource)
return self.http_not_found()
[docs]class ActionsController(BaseController):
FOLDER = 'actions'
def __repr__(self):
return 'ActionsController(folder=%s)' % self.FOLDER
[docs] def show(self, request, resource, **kwargs):
''' Handles GET /actions/{resource} '''
log.debug('%s\nResource: %s\n' % (request, resource))
try:
file_path = self.expand(resource)
body = self.repository.get_file(file_path).read(CONTENT_TYPE_PYTHON)
return dict(body=body, content_type=CONTENT_TYPE_PYTHON)
except FileObjectNotFound:
log.error('Action %s not found' % resource)
return self.http_not_found()
[docs]class NodesController(BaseController):
FOLDER = 'nodes'
def __repr__(self):
return 'NodesController(folder=%s)' % self.FOLDER
[docs] def fsm(self, state, **kwargs):
''' Execute the FSM for the request '''
response = dict()
try:
while state != None:
method = getattr(self, state)
prev_state = state
log.debug('%s: running %s' % (kwargs['node_id'], state))
(response, state) = method(response, **kwargs)
except ValidationError: # pylint: disable=W0703
log.error('%s: validation error in %s' %
(kwargs['node_id'], prev_state))
response = self.http_bad_request()
except Exception as err: # pylint: disable=W0703
log.error('%s: error in %s: %s' %
(kwargs['node_id'], prev_state, str(err)))
response = self.http_bad_request()
log.debug('%s: response to %s: %s' %
(kwargs['node_id'], prev_state, response))
return response # pylint: disable=W0150
#-------------------------------------------------------------------
[docs] def get_config(self, request, resource, **kwargs):
log.debug('%s: node resource GET request: \n%s\n' %
(resource, request))
response = dict()
try:
filename = self.expand(resource, STARTUP_CONFIG_FN)
response['body'] = self.repository.get_file(filename).read()
response['content_type'] = CONTENT_TYPE_OTHER
except FileObjectNotFound:
log.error('%s: missing startup-config file %s' %
(resource, filename))
response = self.http_bad_request()
except Exception as err:
log.error('%s: unable to retrieve startup-config (%s)' %
(resource, err))
response = self.http_bad_request()
return response
#-------------------------------------------------------------------
[docs] def put_config(self, request, **kwargs):
log.debug('%s: startup-config PUT request: \n%s\n' %
(kwargs['resource'], request))
response = dict()
fobj = None
try:
body = str(request.body)
content_type = str(request.content_type)
filename = self.expand(kwargs['resource'], STARTUP_CONFIG_FN)
fobj = self.repository.get_file(filename)
except FileObjectNotFound:
log.debug('%s: file not found: %s (adding it)' %
(kwargs['resource'], filename))
fobj = self.repository.add_file(filename)
finally:
if fobj:
fobj.write(body, content_type)
else:
log.error('%s: unable to write %s' %
(kwargs['resource'], filename))
return self.http_bad_request()
return response
#-------------------------------------------------------------------
[docs] def create(self, request, **kwargs):
""" Handle the POST /nodes request
The create method will handle in incoming POST request from the node
and determine if the node already exists or not. If the node
does not exist, then the node will be created based on the
request body.
Args:
request (webob.Request): the request object from WSGI
Returns:
A dict as the result of the state machine which is used to
create a WSGI response object.
"""
log.debug('%s\n' % request)
try:
node = create_node(request.json)
except Exception as err: # pylint: disable=W0703
log.error('Unable to create node: %s (request=%s)' % (err, request))
response = self.http_bad_request()
return self.response(**response)
node_id = node.identifier()
if not node_id:
log.error('Missing node identifier: %s (request=%s)' %
(node, request))
response = self.http_bad_request()
return self.response(**response)
return self.fsm('node_exists', request=request,
node=node, node_id=node_id)
[docs] def node_exists(self, response, *args, **kwargs):
""" Checks if the node already exists and determines the next state
This method will check for the existence of the node in the
repository based on the node_id. The node_id keyword is pulled
from the kwargs dict.
Args:
response (dict): the response object being constructed
kwargs (dict): arbitrary keyword arguments
Returns:
A tuple that includes the updated response object and the
next state to transition to. If the node already exists
in the repository with a valid definition or startup-config,
then the next state is 'dump_node' otherwise the next state
is 'post_config'
"""
next_state = 'post_config'
node_id = kwargs.get('node_id')
if self.repository.exists(self.expand(node_id, DEFINITION_FN)) or \
self.repository.exists(self.expand(node_id, STARTUP_CONFIG_FN)):
response['status'] = HTTP_STATUS_CONFLICT
next_state = 'dump_node'
else:
if self.repository.exists(self.expand(node_id)):
log.error('%s: node found on server, but no definition '
'or startup-config configured' % node_id)
return (self.http_bad_request(), None)
return (response, next_state)
[docs] def post_config(self, response, *args, **kwargs):
""" Writes the nodes startup config file if found in the request
Args:
response (dict): the response object being constructed
kwargs (dict): arbitrary keyword arguments
Returns:
a tuple of response object and next state. If a config key
was found in the request, the next state is 'set_location'.
If not, the next state is 'post_node'.
"""
if 'config' not in kwargs['request'].json:
# POST request for the node - will try to match neighbordb
next_state = 'post_node'
else:
# POST request for the node's startup-config
config = kwargs['request'].json['config']
node_id = kwargs['node_id']
self.repository.add_folder(self.expand(node_id))
config_fn = self.expand(node_id, STARTUP_CONFIG_FN)
self.repository.add_file(config_fn).write(config)
response['status'] = HTTP_STATUS_CREATED
next_state = 'set_location'
return (response, next_state)
[docs] def post_node(self, response, *args, **kwargs):
""" Checks topology validation matches and writes node specific files
This method will attempt to match the current node against the
defined topology. If a match is found, then the pattern matched
and definition (defined in the pattern) are written to the nodes
folder in the repository and the response status is set to HTTP
201 Created.
Args:
response (dict): the response object being constructed
kwargs (dict): arbitrary keyword arguments
Returns:
a tuple of response object and next state. The next state
is 'dump_node'
Raises:
If a match is not found, then a log message is created and
an IndexError is raised. If the node does not already
exist in the repository, then a log message is created and a
FileObjectNotFound exception is raised
"""
node = kwargs['node']
node_id = kwargs['node_id']
neighbordb = load_neighbordb(node_id)
# pylint: disable=E1103
matches = neighbordb.match_node(node)
if not matches:
log.info('%s: node matched no patterns in neighbordb' %
node_id)
return (self.http_bad_request(), None)
log.debug('%s: %d pattern(s) in neihgbordb are a good match' %
(node_id, len(matches)))
match = matches[0]
log.info('%s: node matched \'%s\' pattern in neighbordb' %
(node_id, match.name))
try:
definition_url = self.expand(match.definition, folder='definitions')
fobj = self.repository.get_file(definition_url)
except FileObjectNotFound:
log.error('%s: failed to find definition (%s)' %
(node_id, definition_url))
raise
try:
definition = fobj.read(content_type=CONTENT_TYPE_YAML)
except FileObjectError:
log.error('%s: failed to load definition' %
(node_id))
raise
definition_fn = self.expand(node_id, DEFINITION_FN)
self.repository.add_folder(self.expand(node_id))
log.info('%s: new /nodes/%s folder created' %
(node_id, node_id))
fobj = self.repository.add_file(definition_fn)
fobj.write(definition, CONTENT_TYPE_YAML)
pattern_fn = self.expand(node_id, PATTERN_FN)
fobj = self.repository.add_file(pattern_fn)
fobj.write(match.serialize(), CONTENT_TYPE_YAML)
response['status'] = HTTP_STATUS_CREATED
return (response, 'dump_node')
[docs] def dump_node(self, response, *args, **kwargs):
""" Writes the contents of the node to the repository
Args:
response (dict): the response object being constructed
kwargs (dict): arbitrary keyword arguments
Returns:
a tuple of response object and next state. The next state is
'set_location'
"""
try:
node = kwargs.get('node')
node_id = kwargs.get('node_id')
contents = node.serialize()
filename = self.expand(node_id, NODE_FN)
fobj = self.repository.get_file(filename)
except FileObjectNotFound:
fobj = self.repository.add_file(filename)
finally:
fobj.write(contents, CONTENT_TYPE_JSON)
return (response, 'set_location')
[docs] def set_location(self, response, *args, **kwargs):
""" Writes the HTTP Content-Location header
Args:
response (dict): the response object being constructed
kwargs (dict): arbitrary keyword arguments
Returns:
a tuple of response object and next state. The next state is
None.
Raises:
Exception: catches a general exception for logging and then
re-raises it
"""
node_id = kwargs.get('node_id')
response['location'] = self.expand(node_id)
return (response, None)
#-------------------------------------------------------------------
[docs] def show(self, request, resource, *args, **kwargs):
""" Handle the GET /nodes/{resource} request
Args:
request (webob.Request): the request object from WSGI
resource (str): the resource being requested
Returns:
A dict as the result of the state machine which is used to
create a WSGI response object.
"""
log.debug('%s\nResource: %s\n' % (request, resource))
node_id = resource.split('/')[0]
try:
fobj = self.repository.get_file(self.expand(resource, NODE_FN))
node = create_node(fobj.read(CONTENT_TYPE_JSON))
except Exception as err: # pylint: disable=W0703
log.error('%s: unable to read file resource %s: %s' %
(node_id, resource, err))
response = self.http_bad_request()
return self.response(**response)
return self.fsm('get_definition', resource=resource, request=request,
node=node, node_id=node_id)
[docs] def get_definition(self, response, *args, **kwargs):
''' Reads the node specific definition from disk and stores it in the
repsonse dict with key `definition`
'''
try:
filename = self.expand(kwargs['resource'], DEFINITION_FN)
fobj = self.repository.get_file(filename)
definition = fobj.read(CONTENT_TYPE_YAML,
kwargs['resource'])
response['definition'] = definition
log.debug('%s: defintion is %s (%s)' % (kwargs['resource'],
filename,
definition['actions']))
except FileObjectNotFound:
log.warning('%s: missing definition %s' %
(kwargs['resource'], filename))
except FileObjectError as err:
log.error(err.message)
raise Exception('failed to load definition %s' % filename)
return (response, 'do_validation')
[docs] def do_validation(self, response, *args, **kwargs):
config = ztpserver.config.runtime
if not config.default.disable_topology_validation:
filename = self.expand(kwargs['resource'], PATTERN_FN)
fobj = self.repository.get_file(filename)
try:
pattern = load_pattern(fobj.name, node_id=kwargs['resource'])
except SerializerError as err:
log.error(err.message)
raise Exception('failed to load pattern %s' % filename)
if not pattern:
raise Exception('failed to validate pattern')
if not pattern.match_node(kwargs['node']):
log.error('%s: node failed pattern validation (%s)' %
(kwargs['resource'], filename))
raise ValidationError('%s: node failed pattern '
'validation (%s)' %
(kwargs['resource'], filename))
log.debug('%s: node passed pattern validation (%s)' %
(kwargs['resource'], filename))
else:
log.warning('%s: topology validation is disabled' %
kwargs['resource'])
return (response, 'get_startup_config')
[docs] def get_startup_config(self, response, *args, **kwargs):
response['get_startup_config'] = False
try:
filename = self.expand(kwargs['resource'], STARTUP_CONFIG_FN)
self.repository.get_file(filename)
response['get_startup_config'] = True
if 'definition' not in response:
response['definition'] = dict(name='Autogenerated definition',
actions=list())
response['definition']['actions'].append(\
replace_config_action(kwargs['resource'],
STARTUP_CONFIG_FN))
except FileObjectNotFound:
log.debug('%s: no startup-config %s' %
(kwargs['resource'], filename))
return (response, 'do_actions')
[docs] def do_actions(self, response, *args, **kwargs):
actions = response['definition']['actions']
_actions = list()
for action in actions:
always_execute = action.get('always_execute', False)
if always_execute:
_actions.append(action)
log.debug('%s: always_execute action %s included '
'in definition' %
(kwargs['resource'], action.get('name')))
elif not response['get_startup_config']:
_actions.append(action)
log.debug('%s: action %s included '
'in definition' %
(kwargs['resource'], action.get('name')))
else:
log.debug('%s: action %s not included '
'in definition' %
(kwargs['resource'], action.get('name')))
response['definition']['actions'] = _actions
return (response, 'get_attributes')
[docs] def get_attributes(self, response, *args, **kwargs):
''' Reads the resource specific attributes file and stores it in the
response dict as 'attributes'
'''
try:
filename = self.expand(kwargs['resource'], ATTRIBUTES_FN)
fileobj = self.repository.get_file(filename)
attributes = fileobj.read(CONTENT_TYPE_YAML)
response['attributes'] = attributes
log.debug('%s: loaded %s attributes from %s' %
(kwargs['resource'], attributes, filename))
except FileObjectNotFound:
log.warning('%s: no node specific attributes file' %
kwargs['resource'])
response['attributes'] = dict()
return (response, 'do_substitution')
[docs] def do_substitution(self, response, *args, **kwargs):
# pylint: disable=R0914
definition = response.get('definition')
attrs = definition.get('attributes', dict())
nodeattrs = response.get('attributes', dict())
def lookup(name):
log.debug('%s: lookup up value for variable %s' %
(kwargs['resource'], name))
return nodeattrs.get(name, attrs.get(name))
_actions = list()
for action in definition['actions']:
log.debug('%s: processing action %s (variable substitution)' %
(kwargs['resource'], action.get('name')))
_attributes = dict()
if 'attributes' in action:
for key, value in action.get('attributes').items():
try:
update = dict()
for _key, _value in value.items():
if str(_value).startswith('$'):
_value = lookup(_value[1:])
update[_key] = _value
except AttributeError:
if str(value).startswith('$'):
value = lookup(value[1:])
update = value
finally:
_attributes[key] = update
action['attributes'] = _attributes
_actions.append(action)
definition['actions'] = _actions
response['definition'] = definition
return (response, 'do_resources')
[docs] def do_resources(self, response, *args, **kwargs):
definition = response['definition']
node = kwargs.get('node')
_actions = list()
try:
for action in definition.get('actions'):
attrs = action.get('attributes', dict())
action['attributes'] = \
resources(attrs, node, kwargs['resource'])
_actions.append(action)
except ResourcePoolError as exc:
log.error(exc)
raise Exception('failed to allocate resources')
definition['actions'] = _actions
response['definition'] = definition
return (response, 'finalize_response')
[docs] def finalize_response(self, response, *args, **kwargs):
_response = dict()
_response['body'] = response['definition']
_response['status'] = response.get('status', 200)
_response['content_type'] = response.get('content_type',
CONTENT_TYPE_JSON)
return (_response, None)
[docs]class BootstrapController(BaseController):
DEFAULT_CONFIG = {
'logging': list(),
'xmpp': dict()
}
FOLDER = 'bootstrap'
def __repr__(self):
return 'BootstrapController(folder=%s)' % self.FOLDER
[docs] def config(self, request, **kwargs):
''' Handles GET /bootstrap/config '''
body = self.DEFAULT_CONFIG
try:
filename = self.expand(BOOTSTRAP_CONF)
config = self.repository.get_file(filename).read(CONTENT_TYPE_YAML)
if not config:
log.warning('Bootstrap config file empty')
else:
if 'logging' in config:
body['logging'] = config['logging']
if 'xmpp' in config:
body['xmpp'] = config['xmpp']
for key in ['username', 'password', 'domain']:
if key not in body['xmpp']:
log.warning('Bootstrap config: \'%s\' missing from '
'XMPP config' % key)
if 'rooms' not in body['xmpp'] or \
not body['xmpp']['rooms']:
log.warning('Bootstrap config: no XMPP rooms '
'configured')
resp = dict(body=body, content_type=CONTENT_TYPE_JSON)
except FileObjectNotFound:
log.warning('Bootstrap config file not found')
resp = dict(body=body, content_type=CONTENT_TYPE_JSON)
except FileObjectError:
log.error('Failed to read bootstrap config file (%s)' % filename)
resp = self.http_bad_request()
except Exception as exc:
log.error('Failed to load bootstrap config file (%s): %s' %
(filename, exc))
resp = self.http_bad_request()
return resp
[docs] def index(self, request, **kwargs):
''' Handles GET /bootstrap '''
try:
filename = self.expand(ztpserver.config.runtime.bootstrap.filename)
fobj = self.repository.get_file(filename).read(CONTENT_TYPE_PYTHON)
default_server = ztpserver.config.runtime.default.server_url
body = Template(fobj).substitute(SERVER=default_server)
resp = dict(body=body, content_type=CONTENT_TYPE_PYTHON)
except KeyError as err:
log.debug('Missing variable: %s' % err)
resp = self.http_bad_request()
except FileObjectNotFound:
log.error('Bootstrap file not found (%s)' % filename)
resp = self.http_bad_request()
except FileObjectError:
log.error('Failed to read bootstrap file (%s)' % filename)
resp = self.http_bad_request()
return resp
[docs]class Router(WSGIRouter):
''' Routes incoming requests by mapping the URL to a controller '''
def __init__(self):
# pylint: disable=E1103,W0142
mapper = routes.Mapper()
kwargs = {}
url = ztpserver.config.runtime.default.server_url
log.debug('server URL: %s', url)
parts = urlparse.urlsplit(url)
if parts.path:
path = parts.path[:-1] if parts.path.endswith('/') else parts.path
if path:
log.debug('path_prefix is %s', path)
kwargs['path_prefix'] = path
with mapper.submapper(**kwargs) as router_mapper:
# configure /bootstrap
router_mapper.connect('bootstrap', '/bootstrap',
controller=BootstrapController,
action='index',
conditions=dict(method=['GET']))
router_mapper.connect('bootstrap_config', '/bootstrap/config',
controller=BootstrapController,
action='config',
conditions=dict(method=['GET']))
# configure /meta
router_mapper.connect('meta',
'/meta/{type:actions|files|nodes}/'
'{path_info:.*}',
controller=MetaController,
action='metadata',
conditions=dict(method=['GET']))
# configure /nodes
router_mapper.collection('nodes', 'node',
controller=NodesController,
collection_actions=['create'],
member_actions=['show'],
member_prefix='/{resource}')
router_mapper.connect('get_node_config',
'/nodes/{resource}/startup-config',
controller=NodesController,
action='get_config',
conditions=dict(method=['GET']))
router_mapper.connect('put_node_config',
'/nodes/{resource}/startup-config',
controller=NodesController,
action='put_config',
conditions=dict(method=['PUT']))
# configure /actions
router_mapper.collection('actions', 'action',
controller=ActionsController,
collection_actions=[],
member_actions=['show'],
member_prefix='/{resource}')
# configure /files
router_mapper.collection('files', 'file',
controller=FilesController,
collection_actions=[],
member_actions=['show'],
member_prefix='/{resource:.*}')
super(Router, self).__init__(mapper)