nso_config module for setting configuration in Cisco NSO (#30973)
This commit is contained in:
parent
4443da2d79
commit
f1fe467c22
16 changed files with 1376 additions and 0 deletions
5
.github/BOTMETA.yml
vendored
5
.github/BOTMETA.yml
vendored
|
@ -470,6 +470,7 @@ files:
|
||||||
$modules/network/netvisor/: $team_netvisor
|
$modules/network/netvisor/: $team_netvisor
|
||||||
$modules/network/nuage/: pdellaert
|
$modules/network/nuage/: pdellaert
|
||||||
$modules/network/nxos/: $team_nxos
|
$modules/network/nxos/: $team_nxos
|
||||||
|
$modules/network/nso/: $team_nso
|
||||||
$modules/network/openvswitch/:
|
$modules/network/openvswitch/:
|
||||||
ignored: stygstra
|
ignored: stygstra
|
||||||
maintainers: $team_networking
|
maintainers: $team_networking
|
||||||
|
@ -998,6 +999,9 @@ files:
|
||||||
$module_utils/network.py:
|
$module_utils/network.py:
|
||||||
maintainers: $team_networking
|
maintainers: $team_networking
|
||||||
labels: networking
|
labels: networking
|
||||||
|
$module_utils/nso.py:
|
||||||
|
maintainers: $team_nso
|
||||||
|
labels: networking
|
||||||
$module_utils/nxos.py:
|
$module_utils/nxos.py:
|
||||||
maintainers: $team_networking
|
maintainers: $team_networking
|
||||||
labels:
|
labels:
|
||||||
|
@ -1205,6 +1209,7 @@ macros:
|
||||||
team_netscaler: chiradeep giorgos-nikolopoulos
|
team_netscaler: chiradeep giorgos-nikolopoulos
|
||||||
team_netvisor: Qalthos amitsi gundalow privateip
|
team_netvisor: Qalthos amitsi gundalow privateip
|
||||||
team_networking: Qalthos ganeshrn gundalow privateip rcarrillocruz trishnaguha kedarX
|
team_networking: Qalthos ganeshrn gundalow privateip rcarrillocruz trishnaguha kedarX
|
||||||
|
team_nso: cmoberg pekdon
|
||||||
team_nxos: GGabriele jedelman8 mikewiebe privateip rahushen rcarrillocruz trishnaguha kedarX
|
team_nxos: GGabriele jedelman8 mikewiebe privateip rahushen rcarrillocruz trishnaguha kedarX
|
||||||
team_openstack: emonty j2sol juliakreger rcarrillocruz shrews thingee dagnello
|
team_openstack: emonty j2sol juliakreger rcarrillocruz shrews thingee dagnello
|
||||||
team_openswitch: Qalthos gundalow privateip
|
team_openswitch: Qalthos gundalow privateip
|
||||||
|
|
|
@ -36,6 +36,7 @@ The following is a list of module_utils files and a general description. The mod
|
||||||
- netcmd.py - Defines commands and comparison operators for use in networking modules
|
- netcmd.py - Defines commands and comparison operators for use in networking modules
|
||||||
- netscaler.py - Utilities specifically for the netscaler network modules.
|
- netscaler.py - Utilities specifically for the netscaler network modules.
|
||||||
- network.py - Functions for running commands on networking devices
|
- network.py - Functions for running commands on networking devices
|
||||||
|
- nso.py - Utilities for modules that work with Cisco NSO.
|
||||||
- nxos.py - Contains definitions and helper functions specific to Cisco NXOS networking devices
|
- nxos.py - Contains definitions and helper functions specific to Cisco NXOS networking devices
|
||||||
- openstack.py - Utilities for modules that work with Openstack instances.
|
- openstack.py - Utilities for modules that work with Openstack instances.
|
||||||
- openswitch.py - Definitions and helper functions for modules that manage OpenSwitch devices
|
- openswitch.py - Definitions and helper functions for modules that manage OpenSwitch devices
|
||||||
|
|
477
lib/ansible/module_utils/nso.py
Normal file
477
lib/ansible/module_utils/nso.py
Normal file
|
@ -0,0 +1,477 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# Copyright (c) 2017 Cisco and/or its affiliates.
|
||||||
|
#
|
||||||
|
# This file is part of Ansible
|
||||||
|
#
|
||||||
|
# Ansible is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# Ansible is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
|
||||||
|
|
||||||
|
from ansible.module_utils.basic import env_fallback
|
||||||
|
from ansible.module_utils.urls import open_url
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
nso_argument_spec = dict(
|
||||||
|
url=dict(required=True),
|
||||||
|
username=dict(fallback=(env_fallback, ['ANSIBLE_NET_USERNAME']), required=True),
|
||||||
|
password=dict(fallback=(env_fallback, ['ANSIBLE_NET_PASSWORD']), required=True, no_log=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class State(object):
|
||||||
|
SET = 'set'
|
||||||
|
PRESENT = 'present'
|
||||||
|
ABSENT = 'absent'
|
||||||
|
CHECK_SYNC = 'check-sync'
|
||||||
|
DEEP_CHECK_SYNC = 'deep-check-sync'
|
||||||
|
IN_SYNC = 'in-sync'
|
||||||
|
DEEP_IN_SYNC = 'deep-in-sync'
|
||||||
|
|
||||||
|
SYNC_STATES = ('check-sync', 'deep-check-sync', 'in-sync', 'deep-in-sync')
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleFailException(Exception):
|
||||||
|
def __init__(self, message):
|
||||||
|
super(ModuleFailException, self).__init__(message)
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
|
||||||
|
class NsoException(Exception):
|
||||||
|
def __init__(self, message, error):
|
||||||
|
super(NsoException, self).__init__(message)
|
||||||
|
self.message = message
|
||||||
|
self.error = error
|
||||||
|
|
||||||
|
|
||||||
|
class JsonRpc(object):
|
||||||
|
def __init__(self, url):
|
||||||
|
self._url = url
|
||||||
|
|
||||||
|
self._id = 0
|
||||||
|
self._trans = {}
|
||||||
|
self._headers = {'Content-Type': 'application/json'}
|
||||||
|
self._conn = None
|
||||||
|
|
||||||
|
def login(self, user, passwd):
|
||||||
|
payload = {
|
||||||
|
'method': 'login',
|
||||||
|
'params': {'user': user, 'passwd': passwd}
|
||||||
|
}
|
||||||
|
resp, resp_json = self._call(payload)
|
||||||
|
self._headers['Cookie'] = resp.headers['set-cookie']
|
||||||
|
|
||||||
|
def logout(self):
|
||||||
|
payload = {'method': 'logout', 'params': {}}
|
||||||
|
self._call(payload)
|
||||||
|
|
||||||
|
def get_system_setting(self, setting):
|
||||||
|
payload = {'method': 'get_system_setting', 'params': {'operation': setting}}
|
||||||
|
resp, resp_json = self._call(payload)
|
||||||
|
return resp_json['result']
|
||||||
|
|
||||||
|
def new_trans(self, **kwargs):
|
||||||
|
payload = {'method': 'new_trans', 'params': kwargs}
|
||||||
|
resp, resp_json = self._call(payload)
|
||||||
|
return resp_json['result']['th']
|
||||||
|
|
||||||
|
def delete_trans(self, th):
|
||||||
|
payload = {'method': 'delete_trans', 'params': {'th': th}}
|
||||||
|
resp, resp_json = self._call(payload)
|
||||||
|
|
||||||
|
def validate_trans(self, th):
|
||||||
|
payload = {'method': 'validate_trans', 'params': {'th': th}}
|
||||||
|
resp, resp_json = self._write_call(payload)
|
||||||
|
return resp_json['result']
|
||||||
|
|
||||||
|
def get_trans_changes(self, th):
|
||||||
|
payload = {'method': 'get_trans_changes', 'params': {'th': th}}
|
||||||
|
resp, resp_json = self._write_call(payload)
|
||||||
|
return resp_json['result']['changes']
|
||||||
|
|
||||||
|
def validate_commit(self, th):
|
||||||
|
payload = {'method': 'validate_commit', 'params': {'th': th}}
|
||||||
|
resp, resp_json = self._write_call(payload)
|
||||||
|
return resp_json['result'].get('warnings', [])
|
||||||
|
|
||||||
|
def commit(self, th):
|
||||||
|
payload = {'method': 'commit', 'params': {'th': th}}
|
||||||
|
resp, resp_json = self._write_call(payload)
|
||||||
|
return resp_json['result']
|
||||||
|
|
||||||
|
def get_schema(self, **kwargs):
|
||||||
|
payload = {'method': 'get_schema', 'params': kwargs}
|
||||||
|
resp, resp_json = self._read_call(payload)
|
||||||
|
return resp_json['result']
|
||||||
|
|
||||||
|
def get_module_prefix_map(self):
|
||||||
|
payload = {'method': 'get_module_prefix_map', 'params': {}}
|
||||||
|
resp, resp_json = self._call(payload)
|
||||||
|
return resp_json['result']
|
||||||
|
|
||||||
|
def get_value(self, path):
|
||||||
|
payload = {
|
||||||
|
'method': 'get_value',
|
||||||
|
'params': {'path': path}
|
||||||
|
}
|
||||||
|
resp, resp_json = self._read_call(payload)
|
||||||
|
return resp_json['result']
|
||||||
|
|
||||||
|
def exists(self, path):
|
||||||
|
payload = {'method': 'exists', 'params': {'path': path}}
|
||||||
|
resp, resp_json = self._read_call(payload)
|
||||||
|
return resp_json['result']['exists']
|
||||||
|
|
||||||
|
def create(self, th, path):
|
||||||
|
payload = {'method': 'create', 'params': {'th': th, 'path': path}}
|
||||||
|
self._write_call(payload)
|
||||||
|
|
||||||
|
def delete(self, th, path):
|
||||||
|
payload = {'method': 'delete', 'params': {'th': th, 'path': path}}
|
||||||
|
self._write_call(payload)
|
||||||
|
|
||||||
|
def set_value(self, th, path, value):
|
||||||
|
payload = {
|
||||||
|
'method': 'set_value',
|
||||||
|
'params': {'th': th, 'path': path, 'value': value}
|
||||||
|
}
|
||||||
|
resp, resp_json = self._write_call(payload)
|
||||||
|
return resp_json['result']
|
||||||
|
|
||||||
|
def run_action(self, th, path, params=None):
|
||||||
|
if params is None:
|
||||||
|
params = {}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'method': 'run_action',
|
||||||
|
'params': {
|
||||||
|
'format': 'normal',
|
||||||
|
'path': path,
|
||||||
|
'params': params
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if th is None:
|
||||||
|
resp, resp_json = self._read_call(payload)
|
||||||
|
else:
|
||||||
|
payload['params']['th'] = th
|
||||||
|
resp, resp_json = self._call(payload)
|
||||||
|
|
||||||
|
return resp_json['result']
|
||||||
|
|
||||||
|
def _call(self, payload):
|
||||||
|
self._id += 1
|
||||||
|
if 'id' not in payload:
|
||||||
|
payload['id'] = self._id
|
||||||
|
|
||||||
|
if 'jsonrpc' not in payload:
|
||||||
|
payload['jsonrpc'] = '2.0'
|
||||||
|
|
||||||
|
data = json.dumps(payload)
|
||||||
|
resp = open_url(
|
||||||
|
self._url, method='POST', data=data, headers=self._headers)
|
||||||
|
if resp.code != 200:
|
||||||
|
raise NsoException(
|
||||||
|
'NSO returned HTTP code {0}, expected 200'.format(resp.status), {})
|
||||||
|
|
||||||
|
resp_body = resp.read()
|
||||||
|
resp_json = json.loads(resp_body)
|
||||||
|
|
||||||
|
if 'error' in resp_json:
|
||||||
|
self._handle_call_error(payload, resp_json)
|
||||||
|
return resp, resp_json
|
||||||
|
|
||||||
|
def _handle_call_error(self, payload, resp_json):
|
||||||
|
method = payload['method']
|
||||||
|
|
||||||
|
error = resp_json['error']
|
||||||
|
error_type = error['type'][len('rpc.method.'):]
|
||||||
|
if error_type in ('unexpected_params',
|
||||||
|
'unknown_params_value',
|
||||||
|
'invalid_params',
|
||||||
|
'invalid_params_type',
|
||||||
|
'data_not_found'):
|
||||||
|
key = error['data']['param']
|
||||||
|
error_type_s = error_type.replace('_', ' ')
|
||||||
|
if key == 'path':
|
||||||
|
msg = 'NSO {0} {1}. path = {2}'.format(
|
||||||
|
method, error_type_s, payload['params']['path'])
|
||||||
|
else:
|
||||||
|
path = payload['params'].get('path', 'unknown')
|
||||||
|
msg = 'NSO {0} {1}. path = {2}. {3} = {4}'.format(
|
||||||
|
method, error_type_s, path, key, payload['params'][key])
|
||||||
|
else:
|
||||||
|
msg = 'NSO {0} returned JSON-RPC error: {1}'.format(method, error)
|
||||||
|
|
||||||
|
raise NsoException(msg, error)
|
||||||
|
|
||||||
|
def _read_call(self, payload):
|
||||||
|
if 'th' not in payload['params']:
|
||||||
|
payload['params']['th'] = self._get_th(mode='read')
|
||||||
|
return self._call(payload)
|
||||||
|
|
||||||
|
def _write_call(self, payload):
|
||||||
|
if 'th' not in payload['params']:
|
||||||
|
payload['params']['th'] = self._get_th(mode='read_write')
|
||||||
|
return self._call(payload)
|
||||||
|
|
||||||
|
def _get_th(self, mode='read'):
|
||||||
|
if mode not in self._trans:
|
||||||
|
th = self.new_trans(mode=mode)
|
||||||
|
self._trans[mode] = th
|
||||||
|
return self._trans[mode]
|
||||||
|
|
||||||
|
|
||||||
|
class ValueBuilder(object):
|
||||||
|
class Value(object):
|
||||||
|
__slots__ = ['path', 'state', 'value']
|
||||||
|
|
||||||
|
def __init__(self, path, state, value):
|
||||||
|
self.path = path
|
||||||
|
self.state = state
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
def __lt__(self, rhs):
|
||||||
|
l_len = len(self.path.split('/'))
|
||||||
|
r_len = len(rhs.path.split('/'))
|
||||||
|
if l_len == r_len:
|
||||||
|
return self.path.__lt__(rhs.path)
|
||||||
|
return l_len < r_len
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return 'Value<path={0}, state={1}, value={2}>'.format(
|
||||||
|
self.path, self.state, self.value)
|
||||||
|
|
||||||
|
def __init__(self, client):
|
||||||
|
self._client = client
|
||||||
|
self._schema_cache = {}
|
||||||
|
self._module_prefix_map_cache = None
|
||||||
|
self._values = []
|
||||||
|
self._values_dirty = False
|
||||||
|
self._path_re = re.compile('{[^}]*}')
|
||||||
|
|
||||||
|
def build(self, parent, maybe_qname, value, schema=None):
|
||||||
|
qname, name = self._get_prefix_name(maybe_qname)
|
||||||
|
if name is None:
|
||||||
|
path = parent
|
||||||
|
else:
|
||||||
|
path = '{0}/{1}'.format(parent, qname)
|
||||||
|
|
||||||
|
if schema is None:
|
||||||
|
schema = self._get_schema(path)
|
||||||
|
|
||||||
|
if self._is_leaf(schema):
|
||||||
|
if self._is_empty_leaf(schema):
|
||||||
|
exists = self._client.exists(path)
|
||||||
|
if exists and value != [None]:
|
||||||
|
self._add_value(path, State.ABSENT, None)
|
||||||
|
elif not exists and value == [None]:
|
||||||
|
self._add_value(path, State.PRESENT, None)
|
||||||
|
else:
|
||||||
|
value_type = self._get_type(parent, maybe_qname)
|
||||||
|
if value_type == 'identityref':
|
||||||
|
value, t_value = self._get_prefix_name(value)
|
||||||
|
self._add_value(path, State.SET, value)
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
self._build_dict(path, schema, value)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
self._build_list(path, schema, value)
|
||||||
|
else:
|
||||||
|
raise ModuleFailException(
|
||||||
|
'unsupported schema {0} at {1}'.format(
|
||||||
|
schema['kind'], path))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def values(self):
|
||||||
|
if self._values_dirty:
|
||||||
|
self._values.sort()
|
||||||
|
self._values_dirty = False
|
||||||
|
|
||||||
|
return self._values
|
||||||
|
|
||||||
|
def _build_dict(self, path, schema, value):
|
||||||
|
keys = schema.get('key', [])
|
||||||
|
for dict_key, dict_value in value.items():
|
||||||
|
qname, name = self._get_prefix_name(dict_key)
|
||||||
|
if dict_key in ('__state', ) or name in keys:
|
||||||
|
continue
|
||||||
|
|
||||||
|
child_schema = self._find_child(path, schema, qname)
|
||||||
|
self.build(path, dict_key, dict_value, child_schema)
|
||||||
|
|
||||||
|
def _build_list(self, path, schema, value):
|
||||||
|
for entry in value:
|
||||||
|
entry_key = self._build_key(path, entry, schema['key'])
|
||||||
|
entry_path = '{0}{{{1}}}'.format(path, entry_key)
|
||||||
|
entry_state = entry.get('__state', 'present')
|
||||||
|
entry_exists = self._client.exists(entry_path)
|
||||||
|
|
||||||
|
if entry_state == 'absent':
|
||||||
|
if entry_exists:
|
||||||
|
self._add_value(entry_path, State.ABSENT, None)
|
||||||
|
else:
|
||||||
|
if not entry_exists:
|
||||||
|
self._add_value(entry_path, State.PRESENT, None)
|
||||||
|
if entry_state in State.SYNC_STATES:
|
||||||
|
self._add_value(entry_path, entry_state, None)
|
||||||
|
|
||||||
|
self.build(entry_path, None, entry)
|
||||||
|
|
||||||
|
def _build_key(self, path, entry, schema_keys):
|
||||||
|
key_parts = []
|
||||||
|
for key in schema_keys:
|
||||||
|
value = entry.get(key, None)
|
||||||
|
if value is None:
|
||||||
|
raise ModuleFailException(
|
||||||
|
'required leaf {0} in {1} not set in data'.format(
|
||||||
|
key, path))
|
||||||
|
|
||||||
|
value_type = self._get_type(path, key)
|
||||||
|
if value_type == 'identityref':
|
||||||
|
value, t_value = self._get_prefix_name(value)
|
||||||
|
key_parts.append(self._quote_key(value))
|
||||||
|
return ' '.join(key_parts)
|
||||||
|
|
||||||
|
def _quote_key(self, key):
|
||||||
|
if isinstance(key, bool):
|
||||||
|
return key and 'true' or 'false'
|
||||||
|
|
||||||
|
q_key = []
|
||||||
|
for c in str(key):
|
||||||
|
if c in ('{', '}', "'", '\\'):
|
||||||
|
q_key.append('\\')
|
||||||
|
q_key.append(c)
|
||||||
|
q_key = ''.join(q_key)
|
||||||
|
if ' ' in q_key:
|
||||||
|
return '{0}'.format(q_key)
|
||||||
|
return q_key
|
||||||
|
|
||||||
|
def _find_child(self, path, schema, qname):
|
||||||
|
if 'children' not in schema:
|
||||||
|
schema = self._get_schema(path)
|
||||||
|
|
||||||
|
# look for the qualified name if : is in the name
|
||||||
|
child_schema = self._get_child(schema, qname)
|
||||||
|
if child_schema is not None:
|
||||||
|
return child_schema
|
||||||
|
|
||||||
|
# no child was found, look for a choice with a child matching
|
||||||
|
for child_schema in schema['children']:
|
||||||
|
if child_schema['kind'] != 'choice':
|
||||||
|
continue
|
||||||
|
choice_child_schema = self._get_choice_child(child_schema, qname)
|
||||||
|
if choice_child_schema is not None:
|
||||||
|
return choice_child_schema
|
||||||
|
|
||||||
|
raise ModuleFailException(
|
||||||
|
'no child in {0} with name {1}. children {2}'.format(
|
||||||
|
path, qname, ','.join((c.get('qname', c.get('name', None)) for c in schema['children']))))
|
||||||
|
|
||||||
|
def _add_value(self, path, state, value):
|
||||||
|
self._values.append(ValueBuilder.Value(path, state, value))
|
||||||
|
self._values_dirty = True
|
||||||
|
|
||||||
|
def _get_prefix_name(self, qname):
|
||||||
|
if qname is None:
|
||||||
|
return None, None
|
||||||
|
if ':' not in qname:
|
||||||
|
return qname, qname
|
||||||
|
|
||||||
|
module_prefix_map = self._get_module_prefix_map()
|
||||||
|
module, name = qname.split(':', 1)
|
||||||
|
if module not in module_prefix_map:
|
||||||
|
raise ModuleFailException(
|
||||||
|
'no module mapping for module {0}. loaded modules {1}'.format(
|
||||||
|
module, ','.join(sorted(module_prefix_map.keys()))))
|
||||||
|
|
||||||
|
return '{0}:{1}'.format(module_prefix_map[module], name), name
|
||||||
|
|
||||||
|
def _get_schema(self, path):
|
||||||
|
return self._ensure_schema_cached(path)['data']
|
||||||
|
|
||||||
|
def _get_type(self, parent_path, key):
|
||||||
|
all_schema = self._ensure_schema_cached(parent_path)
|
||||||
|
parent_schema = all_schema['data']
|
||||||
|
meta = all_schema['meta']
|
||||||
|
|
||||||
|
schema = self._get_child(parent_schema, key)
|
||||||
|
if self._is_leaf(schema):
|
||||||
|
path_type = schema['type']
|
||||||
|
if path_type.get('primitive', False):
|
||||||
|
return path_type['name']
|
||||||
|
else:
|
||||||
|
path_type_key = '{0}:{1}'.format(
|
||||||
|
path_type['namespace'], path_type['name'])
|
||||||
|
type_info = meta['types'][path_type_key]
|
||||||
|
return type_info[-1]['name']
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _ensure_schema_cached(self, path):
|
||||||
|
path = self._path_re.sub('', path)
|
||||||
|
if path not in self._schema_cache:
|
||||||
|
schema = self._client.get_schema(path=path, levels=1)
|
||||||
|
self._schema_cache[path] = schema
|
||||||
|
return self._schema_cache[path]
|
||||||
|
|
||||||
|
def _get_module_prefix_map(self):
|
||||||
|
if self._module_prefix_map_cache is None:
|
||||||
|
self._module_prefix_map_cache = self._client.get_module_prefix_map()
|
||||||
|
return self._module_prefix_map_cache
|
||||||
|
|
||||||
|
def _get_child(self, schema, qname):
|
||||||
|
# no child specified, return parent
|
||||||
|
if qname is None:
|
||||||
|
return schema
|
||||||
|
|
||||||
|
name_key = ':' in qname and 'qname' or 'name'
|
||||||
|
return next((c for c in schema['children']
|
||||||
|
if c.get(name_key, None) == qname), None)
|
||||||
|
|
||||||
|
def _get_choice_child(self, schema, qname):
|
||||||
|
name_key = ':' in qname and 'qname' or 'name'
|
||||||
|
for child_case in schema['cases']:
|
||||||
|
choice_child_schema = next(
|
||||||
|
(c for c in child_case['children']
|
||||||
|
if c.get(name_key, None) == qname), None)
|
||||||
|
if choice_child_schema is not None:
|
||||||
|
return choice_child_schema
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _is_leaf(self, schema):
|
||||||
|
return schema.get('kind', None) in ('key', 'leaf', 'leaf-list')
|
||||||
|
|
||||||
|
def _is_empty_leaf(self, schema):
|
||||||
|
return (schema.get('kind', None) == 'leaf' and
|
||||||
|
schema['type'].get('primitive', False) and
|
||||||
|
schema['type'].get('name', '') == 'empty')
|
||||||
|
|
||||||
|
|
||||||
|
def connect(params):
|
||||||
|
client = JsonRpc(params['url'])
|
||||||
|
client.login(params['username'], params['password'])
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def verify_version(client):
|
||||||
|
version_str = client.get_system_setting('version')
|
||||||
|
version = [int(p) for p in version_str.split('.')]
|
||||||
|
if len(version) < 2:
|
||||||
|
raise ModuleFailException(
|
||||||
|
'unsupported NSO version format {0}'.format(version_str))
|
||||||
|
if (version[0] < 4 or version[1] < 4 or
|
||||||
|
(version[1] == 4 and (len(version) < 3 or version[2] < 3))):
|
||||||
|
raise ModuleFailException(
|
||||||
|
'unsupported NSO version {0}, only 4.4.3 or later is supported'.format(version_str))
|
0
lib/ansible/modules/network/nso/__init__.py
Normal file
0
lib/ansible/modules/network/nso/__init__.py
Normal file
276
lib/ansible/modules/network/nso/nso_config.py
Normal file
276
lib/ansible/modules/network/nso/nso_config.py
Normal file
|
@ -0,0 +1,276 @@
|
||||||
|
#!/usr/bin/python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# Copyright (c) 2017 Cisco and/or its affiliates.
|
||||||
|
#
|
||||||
|
# This file is part of Ansible
|
||||||
|
#
|
||||||
|
# Ansible is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# Ansible is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
|
||||||
|
from __future__ import (absolute_import, division, print_function)
|
||||||
|
__metaclass__ = type
|
||||||
|
|
||||||
|
ANSIBLE_METADATA = {
|
||||||
|
'metadata_version': '1.1',
|
||||||
|
'status': ['preview'],
|
||||||
|
'supported_by': 'community'
|
||||||
|
}
|
||||||
|
|
||||||
|
DOCUMENTATION = '''
|
||||||
|
---
|
||||||
|
module: nso_config
|
||||||
|
extends_documentation_fragment: nso
|
||||||
|
short_description: Manage Cisco NSO configuration and service synchronization.
|
||||||
|
description:
|
||||||
|
- This module provides support for managing configuration in Cisco NSO and
|
||||||
|
can also ensure services are in sync.
|
||||||
|
author: "Claes Nästén (@cnasten)"
|
||||||
|
options:
|
||||||
|
data:
|
||||||
|
description: >
|
||||||
|
NSO data in format as | display json converted to YAML. List entries can
|
||||||
|
be annotated with a __state entry. Set to in-sync/deep-in-sync for
|
||||||
|
services to verify service is in sync with the network. Set to absent in
|
||||||
|
list entries to ensure they are deleted if they exist in NSO.
|
||||||
|
required: true
|
||||||
|
version_added: "2.5"
|
||||||
|
'''
|
||||||
|
|
||||||
|
EXAMPLES = '''
|
||||||
|
- name: Create L3VPN
|
||||||
|
nso_config:
|
||||||
|
url: http://localhost:8080/jsonrpc
|
||||||
|
username: username
|
||||||
|
password: password
|
||||||
|
data:
|
||||||
|
l3vpn:vpn:
|
||||||
|
l3vpn:
|
||||||
|
- name: company
|
||||||
|
route-distinguisher: 999
|
||||||
|
endpoint:
|
||||||
|
- id: branch-office1
|
||||||
|
ce-device: ce6
|
||||||
|
ce-interface: GigabitEthernet0/12
|
||||||
|
ip-network: 10.10.1.0/24
|
||||||
|
bandwidth: 12000000
|
||||||
|
as-number: 65101
|
||||||
|
- id: branch-office2
|
||||||
|
ce-device: ce1
|
||||||
|
ce-interface: GigabitEthernet0/11
|
||||||
|
ip-network: 10.7.7.0/24
|
||||||
|
bandwidth: 6000000
|
||||||
|
as-number: 65102
|
||||||
|
- id: branch-office3
|
||||||
|
__state: absent
|
||||||
|
__state: in-sync
|
||||||
|
'''
|
||||||
|
|
||||||
|
RETURN = '''
|
||||||
|
changes:
|
||||||
|
description: List of changes
|
||||||
|
returned: always
|
||||||
|
type: complex
|
||||||
|
sample:
|
||||||
|
- path: "/l3vpn:vpn/l3vpn{example}/endpoint{office}/bandwidth"
|
||||||
|
from: '6000000'
|
||||||
|
to: '12000000'
|
||||||
|
type: set
|
||||||
|
contains:
|
||||||
|
path:
|
||||||
|
description: Path to value changed
|
||||||
|
returned: always
|
||||||
|
type: string
|
||||||
|
from:
|
||||||
|
description: Previous value if any, else null
|
||||||
|
returned: When previous value is present on value change
|
||||||
|
type: string
|
||||||
|
to:
|
||||||
|
description: Current value if any, else null.
|
||||||
|
returned: When new value is present on value change
|
||||||
|
type:
|
||||||
|
description: Type of change. create|delete|set|re-deploy
|
||||||
|
diffs:
|
||||||
|
description: List of sync changes
|
||||||
|
returned: always
|
||||||
|
type: complex
|
||||||
|
sample:
|
||||||
|
- path: "/l3vpn:vpn/l3vpn{example}"
|
||||||
|
diff: |2
|
||||||
|
devices {
|
||||||
|
device pe3 {
|
||||||
|
config {
|
||||||
|
alu:service {
|
||||||
|
vprn 65101 {
|
||||||
|
bgp {
|
||||||
|
group example-ce6 {
|
||||||
|
- peer-as 65102;
|
||||||
|
+ peer-as 65101;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contains:
|
||||||
|
path:
|
||||||
|
description: keypath to service changed
|
||||||
|
returned: always
|
||||||
|
type: string
|
||||||
|
diff:
|
||||||
|
description: configuration difference triggered the re-deploy
|
||||||
|
returned: always
|
||||||
|
type: string
|
||||||
|
'''
|
||||||
|
|
||||||
|
from ansible.module_utils.nso import connect, verify_version, nso_argument_spec
|
||||||
|
from ansible.module_utils.nso import State, ValueBuilder
|
||||||
|
from ansible.module_utils.nso import ModuleFailException, NsoException
|
||||||
|
from ansible.module_utils.basic import AnsibleModule
|
||||||
|
|
||||||
|
|
||||||
|
class NsoConfig(object):
|
||||||
|
def __init__(self, check_mode, client, data):
|
||||||
|
self._check_mode = check_mode
|
||||||
|
self._client = client
|
||||||
|
self._data = data
|
||||||
|
|
||||||
|
self._changes = []
|
||||||
|
self._diffs = []
|
||||||
|
|
||||||
|
def main(self):
|
||||||
|
# build list of values from configured data
|
||||||
|
value_builder = ValueBuilder(self._client)
|
||||||
|
for key, value in self._data.items():
|
||||||
|
value_builder.build('', key, value)
|
||||||
|
|
||||||
|
self._data_write(value_builder.values)
|
||||||
|
|
||||||
|
# check sync AFTER configuration is written
|
||||||
|
sync_values = self._sync_check(value_builder.values)
|
||||||
|
self._sync_ensure(sync_values)
|
||||||
|
|
||||||
|
return self._changes, self._diffs
|
||||||
|
|
||||||
|
def _data_write(self, values):
|
||||||
|
th = self._client.new_trans(mode='read_write')
|
||||||
|
|
||||||
|
for value in values:
|
||||||
|
if value.state == State.SET:
|
||||||
|
self._client.set_value(th, value.path, value.value)
|
||||||
|
elif value.state == State.PRESENT:
|
||||||
|
self._client.create(th, value.path)
|
||||||
|
elif value.state == State.ABSENT:
|
||||||
|
self._client.delete(th, value.path)
|
||||||
|
|
||||||
|
changes = self._client.get_trans_changes(th)
|
||||||
|
for change in changes:
|
||||||
|
if change['op'] == 'value_set':
|
||||||
|
self._changes.append({
|
||||||
|
'path': change['path'],
|
||||||
|
'from': change['old'] or None,
|
||||||
|
'to': change['value'],
|
||||||
|
'type': 'set'
|
||||||
|
})
|
||||||
|
elif change['op'] in ('created', 'deleted'):
|
||||||
|
self._changes.append({
|
||||||
|
'path': change['path'],
|
||||||
|
'type': change['op'][:-1]
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(changes) > 0:
|
||||||
|
warnings = self._client.validate_commit(th)
|
||||||
|
if len(warnings) > 0:
|
||||||
|
raise NsoException(
|
||||||
|
'failed to validate transaction with warnings: {0}'.format(
|
||||||
|
', '.join((str(warning) for warning in warnings))), {})
|
||||||
|
|
||||||
|
if self._check_mode or len(changes) == 0:
|
||||||
|
self._client.delete_trans(th)
|
||||||
|
else:
|
||||||
|
self._client.commit(th)
|
||||||
|
|
||||||
|
def _sync_check(self, values):
|
||||||
|
sync_values = []
|
||||||
|
|
||||||
|
for value in values:
|
||||||
|
if value.state in (State.CHECK_SYNC, State.IN_SYNC):
|
||||||
|
action = 'check-sync'
|
||||||
|
elif value.state in (State.DEEP_CHECK_SYNC, State.DEEP_IN_SYNC):
|
||||||
|
action = 'deep-check-sync'
|
||||||
|
else:
|
||||||
|
action = None
|
||||||
|
|
||||||
|
if action is not None:
|
||||||
|
action_path = '{0}/{1}'.format(value.path, action)
|
||||||
|
action_params = {'outformat': 'cli'}
|
||||||
|
resp = self._client.run_action(None, action_path, action_params)
|
||||||
|
if len(resp) > 0:
|
||||||
|
sync_values.append(
|
||||||
|
ValueBuilder.Value(value.path, value.state, resp[0]['value']))
|
||||||
|
|
||||||
|
return sync_values
|
||||||
|
|
||||||
|
def _sync_ensure(self, sync_values):
|
||||||
|
for value in sync_values:
|
||||||
|
if value.state in (State.CHECK_SYNC, State.DEEP_CHECK_SYNC):
|
||||||
|
raise NsoException(
|
||||||
|
'{0} out of sync, diff {1}'.format(value.path, value.value), {})
|
||||||
|
|
||||||
|
action_path = '{0}/{1}'.format(value.path, 're-deploy')
|
||||||
|
if not self._check_mode:
|
||||||
|
result = self._client.run_action(None, action_path)
|
||||||
|
if not result:
|
||||||
|
raise NsoException(
|
||||||
|
'failed to re-deploy {0}'.format(value.path), {})
|
||||||
|
|
||||||
|
self._changes.append({'path': value.path, 'type': 're-deploy'})
|
||||||
|
self._diffs.append({'path': value.path, 'diff': value.value})
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
argument_spec = dict(
|
||||||
|
data=dict(required=True, type='dict')
|
||||||
|
)
|
||||||
|
argument_spec.update(nso_argument_spec)
|
||||||
|
|
||||||
|
module = AnsibleModule(
|
||||||
|
argument_spec=argument_spec,
|
||||||
|
supports_check_mode=True
|
||||||
|
)
|
||||||
|
p = module.params
|
||||||
|
|
||||||
|
client = connect(p)
|
||||||
|
nso_config = NsoConfig(module.check_mode, client, p['data'])
|
||||||
|
try:
|
||||||
|
verify_version(client)
|
||||||
|
|
||||||
|
changes, diffs = nso_config.main()
|
||||||
|
client.logout()
|
||||||
|
|
||||||
|
changed = len(changes) > 0
|
||||||
|
module.exit_json(
|
||||||
|
changed=changed, changes=changes, diffs=diffs)
|
||||||
|
|
||||||
|
except NsoException as ex:
|
||||||
|
client.logout()
|
||||||
|
module.fail_json(msg=ex.message)
|
||||||
|
except ModuleFailException as ex:
|
||||||
|
client.logout()
|
||||||
|
module.fail_json(msg=ex.message)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
37
lib/ansible/utils/module_docs_fragments/nso.py
Normal file
37
lib/ansible/utils/module_docs_fragments/nso.py
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# Copyright (c) 2017 Cisco and/or its affiliates.
|
||||||
|
#
|
||||||
|
# This file is part of Ansible
|
||||||
|
#
|
||||||
|
# Ansible is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# Ansible is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleDocFragment(object):
|
||||||
|
|
||||||
|
DOCUMENTATION = '''
|
||||||
|
notes:
|
||||||
|
- Cisco NSO version 4.4.3 or higher required.
|
||||||
|
options:
|
||||||
|
url:
|
||||||
|
description: NSO JSON-RPC URL, http://localhost:8080/jsonrpc
|
||||||
|
required: true
|
||||||
|
username:
|
||||||
|
description: NSO username
|
||||||
|
required: true
|
||||||
|
password:
|
||||||
|
description: NSO password
|
||||||
|
required: true
|
||||||
|
'''
|
245
test/units/module_utils/test_nso.py
Normal file
245
test/units/module_utils/test_nso.py
Normal file
|
@ -0,0 +1,245 @@
|
||||||
|
# Copyright (c) 2017 Cisco and/or its affiliates.
|
||||||
|
#
|
||||||
|
# This file is part of Ansible
|
||||||
|
#
|
||||||
|
# Ansible is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# Ansible is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
|
||||||
|
from __future__ import (absolute_import, division, print_function)
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from ansible.compat.tests.mock import patch
|
||||||
|
from ansible.compat.tests import unittest
|
||||||
|
from ansible.module_utils import nso
|
||||||
|
|
||||||
|
|
||||||
|
MODULE_PREFIX_MAP = '''
|
||||||
|
{
|
||||||
|
"ansible-nso": "an",
|
||||||
|
"tailf-ncs": "ncs"
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_DATA = {
|
||||||
|
'/an:id-name-leaf': '''
|
||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"prefix": "an",
|
||||||
|
"namespace": "http://github.com/ansible/nso",
|
||||||
|
"types": {
|
||||||
|
"http://github.com/ansible/nso:id-name-t": [
|
||||||
|
{
|
||||||
|
"name": "http://github.com/ansible/nso:id-name-t",
|
||||||
|
"enumeration": [
|
||||||
|
{
|
||||||
|
"label": "id-one"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "id-two"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "identityref"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"keypath": "/an:id-name-leaf"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"kind": "leaf",
|
||||||
|
"type": {
|
||||||
|
"namespace": "http://github.com/ansible/nso",
|
||||||
|
"name": "id-name-t"
|
||||||
|
},
|
||||||
|
"name": "id-name-leaf",
|
||||||
|
"qname": "an:id-name-leaf"
|
||||||
|
}
|
||||||
|
}''',
|
||||||
|
'/an:id-name-values': '''
|
||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"prefix": "an",
|
||||||
|
"namespace": "http://github.com/ansible/nso",
|
||||||
|
"types": {},
|
||||||
|
"keypath": "/an:id-name-values"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"kind": "container",
|
||||||
|
"name": "id-name-values",
|
||||||
|
"qname": "an:id-name-values",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "list",
|
||||||
|
"name": "id-name-value",
|
||||||
|
"qname": "an:id-name-value",
|
||||||
|
"key": [
|
||||||
|
"name"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
''',
|
||||||
|
'/an:id-name-values/id-name-value': '''
|
||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"prefix": "an",
|
||||||
|
"namespace": "http://github.com/ansible/nso",
|
||||||
|
"types": {
|
||||||
|
"http://github.com/ansible/nso:id-name-t": [
|
||||||
|
{
|
||||||
|
"name": "http://github.com/ansible/nso:id-name-t",
|
||||||
|
"enumeration": [
|
||||||
|
{
|
||||||
|
"label": "id-one"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "id-two"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "identityref"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"keypath": "/an:id-name-values/id-name-value"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"kind": "list",
|
||||||
|
"name": "id-name-value",
|
||||||
|
"qname": "an:id-name-value",
|
||||||
|
"key": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "key",
|
||||||
|
"name": "name",
|
||||||
|
"qname": "an:name",
|
||||||
|
"type": {
|
||||||
|
"namespace": "http://github.com/ansible/nso",
|
||||||
|
"name": "id-name-t"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "leaf",
|
||||||
|
"type": {
|
||||||
|
"primitive": true,
|
||||||
|
"name": "string"
|
||||||
|
},
|
||||||
|
"name": "value",
|
||||||
|
"qname": "an:value"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MockResponse(object):
|
||||||
|
def __init__(self, method, params, code, body, headers=None):
|
||||||
|
if headers is None:
|
||||||
|
headers = {}
|
||||||
|
|
||||||
|
self.method = method
|
||||||
|
self.params = params
|
||||||
|
|
||||||
|
self.code = code
|
||||||
|
self.body = body
|
||||||
|
self.headers = dict(headers)
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return self.body
|
||||||
|
|
||||||
|
|
||||||
|
def mock_call(calls, url, data=None, headers=None, method=None):
|
||||||
|
result = calls[0]
|
||||||
|
del calls[0]
|
||||||
|
|
||||||
|
request = json.loads(data)
|
||||||
|
if result.method != request['method']:
|
||||||
|
raise ValueError('expected method {0}({1}), got {2}({3})'.format(
|
||||||
|
result.method, result.params,
|
||||||
|
request['method'], request['params']))
|
||||||
|
|
||||||
|
for key, value in result.params.items():
|
||||||
|
if key not in request['params']:
|
||||||
|
raise ValueError('{0} not in parameters'.format(key))
|
||||||
|
if value != request['params'][key]:
|
||||||
|
raise ValueError('expected {0} to be {1}, got {2}'.format(
|
||||||
|
key, value, request['params'][key]))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_schema_response(path):
|
||||||
|
return MockResponse(
|
||||||
|
'get_schema', {'path': path}, 200, '{{"result": {0}}}'.format(
|
||||||
|
SCHEMA_DATA[path]))
|
||||||
|
|
||||||
|
|
||||||
|
class TestValueBuilder(unittest.TestCase):
|
||||||
|
@patch('ansible.module_utils.nso.open_url')
|
||||||
|
def test_identityref_leaf(self, open_url_mock):
|
||||||
|
calls = [
|
||||||
|
MockResponse('new_trans', {}, 200, '{"result": {"th": 1}}'),
|
||||||
|
get_schema_response('/an:id-name-leaf'),
|
||||||
|
MockResponse('get_module_prefix_map', {}, 200, '{{"result": {0}}}'.format(MODULE_PREFIX_MAP))
|
||||||
|
]
|
||||||
|
open_url_mock.side_effect = lambda *args, **kwargs: mock_call(calls, *args, **kwargs)
|
||||||
|
|
||||||
|
parent = "/an:id-name-leaf"
|
||||||
|
schema_data = json.loads(
|
||||||
|
SCHEMA_DATA['/an:id-name-leaf'])
|
||||||
|
schema = schema_data['data']
|
||||||
|
|
||||||
|
vb = nso.ValueBuilder(nso.JsonRpc('http://localhost:8080/jsonrpc'))
|
||||||
|
vb.build(parent, None, 'ansible-nso:id-two', schema)
|
||||||
|
self.assertEquals(1, len(vb.values))
|
||||||
|
value = vb.values[0]
|
||||||
|
self.assertEquals(parent, value.path)
|
||||||
|
self.assertEquals('set', value.state)
|
||||||
|
self.assertEquals('an:id-two', value.value)
|
||||||
|
|
||||||
|
self.assertEqual(0, len(calls))
|
||||||
|
|
||||||
|
@patch('ansible.module_utils.nso.open_url')
|
||||||
|
def test_identityref_key(self, open_url_mock):
|
||||||
|
calls = [
|
||||||
|
MockResponse('new_trans', {}, 200, '{"result": {"th": 1}}'),
|
||||||
|
get_schema_response('/an:id-name-values/id-name-value'),
|
||||||
|
MockResponse('get_module_prefix_map', {}, 200, '{{"result": {0}}}'.format(MODULE_PREFIX_MAP)),
|
||||||
|
MockResponse('exists', {'path': '/an:id-name-values/id-name-value{an:id-one}'}, 200, '{"result": {"exists": true}}'),
|
||||||
|
]
|
||||||
|
open_url_mock.side_effect = lambda *args, **kwargs: mock_call(calls, *args, **kwargs)
|
||||||
|
|
||||||
|
parent = "/an:id-name-values"
|
||||||
|
schema_data = json.loads(
|
||||||
|
SCHEMA_DATA['/an:id-name-values/id-name-value'])
|
||||||
|
schema = schema_data['data']
|
||||||
|
|
||||||
|
vb = nso.ValueBuilder(nso.JsonRpc('http://localhost:8080/jsonrpc'))
|
||||||
|
vb.build(parent, 'id-name-value', [{'name': 'ansible-nso:id-one', 'value': '1'}], schema)
|
||||||
|
self.assertEquals(1, len(vb.values))
|
||||||
|
value = vb.values[0]
|
||||||
|
self.assertEquals('{0}/id-name-value{{an:id-one}}/value'.format(parent), value.path)
|
||||||
|
self.assertEquals('set', value.state)
|
||||||
|
self.assertEquals('1', value.value)
|
||||||
|
|
||||||
|
self.assertEqual(0, len(calls))
|
0
test/units/modules/network/nso/__init__.py
Normal file
0
test/units/modules/network/nso/__init__.py
Normal file
20
test/units/modules/network/nso/fixtures/config_config.json
Normal file
20
test/units/modules/network/nso/fixtures/config_config.json
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"l3vpn:vpn": {
|
||||||
|
"l3vpn": [
|
||||||
|
{
|
||||||
|
"name": "company",
|
||||||
|
"route-distinguisher": 999,
|
||||||
|
"endpoint": [
|
||||||
|
{
|
||||||
|
"id": "branch-office1",
|
||||||
|
"ce-device": "ce6",
|
||||||
|
"ce-interface": "GigabitEthernet0/12",
|
||||||
|
"ip-network": "10.10.1.0/24",
|
||||||
|
"bandwidth": 12000000,
|
||||||
|
"as-number": 65101
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,46 @@
|
||||||
|
{
|
||||||
|
"changes": [
|
||||||
|
{
|
||||||
|
"path": "/l3vpn:vpn/l3vpn{company}/endpoint{branch-office1}/ce-device",
|
||||||
|
"old": "",
|
||||||
|
"value": "ce6",
|
||||||
|
"op": "value_set"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/l3vpn:vpn/l3vpn{company}/endpoint{branch-office1}/ip-network",
|
||||||
|
"old": "",
|
||||||
|
"value": "10.10.1.0/24",
|
||||||
|
"op": "value_set"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/l3vpn:vpn/l3vpn{company}/endpoint{branch-office1}/as-number",
|
||||||
|
"old": "",
|
||||||
|
"value": "65101",
|
||||||
|
"op": "value_set"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/l3vpn:vpn/l3vpn{company}/endpoint{branch-office1}/ce-interface",
|
||||||
|
"old": "",
|
||||||
|
"value": "GigabitEthernet0/12",
|
||||||
|
"op": "value_set"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/l3vpn:vpn/l3vpn{company}/endpoint{branch-office1}/bandwidth",
|
||||||
|
"old": "",
|
||||||
|
"value": "12000000",
|
||||||
|
"op": "value_set"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/l3vpn:vpn/l3vpn{company}/endpoint{branch-office1}",
|
||||||
|
"old": "",
|
||||||
|
"value": "",
|
||||||
|
"op": "created"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/l3vpn:vpn/l3vpn{company}",
|
||||||
|
"old": "",
|
||||||
|
"value": "",
|
||||||
|
"op": "modified"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
{}
|
|
@ -0,0 +1 @@
|
||||||
|
{"meta": {"prefix": "l3vpn", "namespace": "http://com/example/l3vpn", "types": {}, "keypath": "/l3vpn:vpn/l3vpn/endpoint"}, "data": {"kind": "list", "leafref_groups": [["ce-device"]], "min_elements": 0, "name": "endpoint", "max_elements": "unbounded", "qname": "l3vpn:endpoint", "children": [{"info": {"string": "Endpoint identifier"}, "kind": "key", "mandatory": true, "name": "id", "qname": "l3vpn:id", "access": {"read": true, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "string"}}, {"kind": "leaf", "mandatory": true, "name": "ce-device", "type": {"primitive": true, "name": "string"}, "qname": "l3vpn:ce-device", "access": {"read": true, "create": false, "execute": false, "update": true, "delete": false}, "leafref_target": "/ncs:devices/device/name", "is_leafref": true}, {"kind": "leaf", "mandatory": true, "name": "ce-interface", "qname": "l3vpn:ce-interface", "access": {"read": true, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "string"}}, {"kind": "leaf", "mandatory": true, "name": "ip-network", "qname": "l3vpn:ip-network", "access": {"read": true, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "ip-prefix"}}, {"info": {"string": "Bandwidth in bps"}, "kind": "leaf", "mandatory": true, "name": "bandwidth", "qname": "l3vpn:bandwidth", "access": {"read": true, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "uint32"}}, {"info": {"string": "CE Router as-number"}, "kind": "leaf", "name": "as-number", "qname": "l3vpn:as-number", "access": {"read": true, "create": true, "execute": false, "update": true, "delete": true}, "type": {"primitive": true, "name": "uint32"}}], "access": {"read": true, "create": true, "execute": false, "update": true, "delete": true}, "key": ["id"], "mandatory": true, "leafrefGroups": [["ce-device"]]}}
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
131
test/units/modules/network/nso/nso_module.py
Normal file
131
test/units/modules/network/nso/nso_module.py
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
# Copyright (c) 2017 Cisco and/or its affiliates.
|
||||||
|
#
|
||||||
|
# This file is part of Ansible
|
||||||
|
#
|
||||||
|
# Ansible is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# Ansible is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from __future__ import (absolute_import, division, print_function)
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
|
from ansible.compat.tests import unittest
|
||||||
|
from ansible.compat.tests.mock import patch
|
||||||
|
from ansible.module_utils import basic
|
||||||
|
from ansible.module_utils._text import to_bytes
|
||||||
|
|
||||||
|
|
||||||
|
fixture_path = os.path.join(os.path.dirname(__file__), 'fixtures')
|
||||||
|
fixture_data = {}
|
||||||
|
|
||||||
|
|
||||||
|
def set_module_args(args):
|
||||||
|
args = json.dumps({'ANSIBLE_MODULE_ARGS': args})
|
||||||
|
basic._ANSIBLE_ARGS = to_bytes(args)
|
||||||
|
|
||||||
|
|
||||||
|
def load_fixture(name):
|
||||||
|
path = os.path.join(fixture_path, name)
|
||||||
|
if path not in fixture_data:
|
||||||
|
with open(path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
fixture_data[path] = data
|
||||||
|
return fixture_data[path]
|
||||||
|
|
||||||
|
|
||||||
|
class MockResponse(object):
|
||||||
|
def __init__(self, method, params, code, body, headers=None):
|
||||||
|
if headers is None:
|
||||||
|
headers = {}
|
||||||
|
|
||||||
|
self.method = method
|
||||||
|
self.params = params
|
||||||
|
|
||||||
|
self.code = code
|
||||||
|
self.body = body
|
||||||
|
self.headers = dict(headers)
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return self.body
|
||||||
|
|
||||||
|
|
||||||
|
def mock_call(calls, url, data=None, headers=None, method=None):
|
||||||
|
result = calls[0]
|
||||||
|
del calls[0]
|
||||||
|
|
||||||
|
request = json.loads(data)
|
||||||
|
if result.method != request['method']:
|
||||||
|
raise ValueError('expected method {0}({1}), got {2}({3})'.format(
|
||||||
|
result.method, result.params,
|
||||||
|
request['method'], request['params']))
|
||||||
|
|
||||||
|
for key, value in result.params.items():
|
||||||
|
if key not in request['params']:
|
||||||
|
raise ValueError('{0} not in parameters'.format(key))
|
||||||
|
if value != request['params'][key]:
|
||||||
|
raise ValueError('expected {0} to be {1}, got {2}'.format(
|
||||||
|
key, value, request['params'][key]))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class AnsibleExitJson(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AnsibleFailJson(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestNsoModule(unittest.TestCase):
|
||||||
|
|
||||||
|
def execute_module(self, failed=False, changed=False, **kwargs):
|
||||||
|
if failed:
|
||||||
|
result = self.failed()
|
||||||
|
self.assertTrue(result['failed'], result)
|
||||||
|
else:
|
||||||
|
result = self.changed(changed)
|
||||||
|
self.assertEqual(result['changed'], changed, result)
|
||||||
|
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
self.assertEqual(value, result[key])
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def failed(self):
|
||||||
|
def fail_json(*args, **kwargs):
|
||||||
|
kwargs['failed'] = True
|
||||||
|
raise AnsibleFailJson(kwargs)
|
||||||
|
|
||||||
|
with patch.object(basic.AnsibleModule, 'fail_json', fail_json):
|
||||||
|
with self.assertRaises(AnsibleFailJson) as exc:
|
||||||
|
self.module.main()
|
||||||
|
|
||||||
|
result = exc.exception.args[0]
|
||||||
|
self.assertTrue(result['failed'], result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def changed(self, changed=False):
|
||||||
|
def exit_json(*args, **kwargs):
|
||||||
|
if 'changed' not in kwargs:
|
||||||
|
kwargs['changed'] = False
|
||||||
|
raise AnsibleExitJson(kwargs)
|
||||||
|
|
||||||
|
with patch.object(basic.AnsibleModule, 'exit_json', exit_json):
|
||||||
|
with self.assertRaises(AnsibleExitJson) as exc:
|
||||||
|
self.module.main()
|
||||||
|
|
||||||
|
result = exc.exception.args[0]
|
||||||
|
self.assertEqual(result['changed'], changed, result)
|
||||||
|
return result
|
134
test/units/modules/network/nso/test_nso_config.py
Normal file
134
test/units/modules/network/nso/test_nso_config.py
Normal file
|
@ -0,0 +1,134 @@
|
||||||
|
#
|
||||||
|
# Copyright (c) 2017 Cisco and/or its affiliates.
|
||||||
|
#
|
||||||
|
# This file is part of Ansible
|
||||||
|
#
|
||||||
|
# Ansible is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# Ansible is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from __future__ import (absolute_import, division, print_function)
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from ansible.compat.tests.mock import patch
|
||||||
|
from ansible.modules.network.nso import nso_config
|
||||||
|
from . import nso_module
|
||||||
|
from .nso_module import MockResponse
|
||||||
|
|
||||||
|
|
||||||
|
class TestNsoConfig(nso_module.TestNsoModule):
|
||||||
|
module = nso_config
|
||||||
|
|
||||||
|
@patch('ansible.module_utils.nso.open_url')
|
||||||
|
def test_nso_config_invalid_version_short(self, open_url_mock):
|
||||||
|
self._test_invalid_version(open_url_mock, '4.4')
|
||||||
|
|
||||||
|
@patch('ansible.module_utils.nso.open_url')
|
||||||
|
def test_nso_config_invalid_version_long(self, open_url_mock):
|
||||||
|
self._test_invalid_version(open_url_mock, '4.4.2')
|
||||||
|
|
||||||
|
def _test_invalid_version(self, open_url_mock, version):
|
||||||
|
calls = [
|
||||||
|
MockResponse('login', {}, 200, '{}', {'set-cookie': 'id'}),
|
||||||
|
MockResponse('get_system_setting', {'operation': 'version'}, 200, '{"result": "%s"}' % (version, )),
|
||||||
|
MockResponse('logout', {}, 200, '{"result": {}}'),
|
||||||
|
]
|
||||||
|
open_url_mock.side_effect = lambda *args, **kwargs: nso_module.mock_call(calls, *args, **kwargs)
|
||||||
|
|
||||||
|
data = nso_module.load_fixture('config_config.json')
|
||||||
|
nso_module.set_module_args({
|
||||||
|
'username': 'user', 'password': 'password',
|
||||||
|
'url': 'http://localhost:8080/jsonrpc', 'data': data
|
||||||
|
})
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.execute_module(changed=False, changes=[], diffs=[])
|
||||||
|
|
||||||
|
self.assertEqual(0, len(calls))
|
||||||
|
|
||||||
|
@patch('ansible.module_utils.nso.open_url')
|
||||||
|
def test_nso_config_valid_version_short(self, open_url_mock):
|
||||||
|
self._test_valid_version(open_url_mock, '4.5')
|
||||||
|
|
||||||
|
@patch('ansible.module_utils.nso.open_url')
|
||||||
|
def test_nso_config_valid_version_long(self, open_url_mock):
|
||||||
|
self._test_valid_version(open_url_mock, '4.4.3')
|
||||||
|
|
||||||
|
def _test_valid_version(self, open_url_mock, version):
|
||||||
|
calls = [
|
||||||
|
MockResponse('login', {}, 200, '{}', {'set-cookie': 'id'}),
|
||||||
|
MockResponse('get_system_setting', {'operation': 'version'}, 200, '{"result": "%s"}' % (version, )),
|
||||||
|
MockResponse('new_trans', {}, 200, '{"result": {"th": 1}}'),
|
||||||
|
MockResponse('get_trans_changes', {}, 200, '{"result": {"changes": []}}'),
|
||||||
|
MockResponse('delete_trans', {}, 200, '{"result": {}}'),
|
||||||
|
MockResponse('logout', {}, 200, '{"result": {}}'),
|
||||||
|
]
|
||||||
|
open_url_mock.side_effect = lambda *args, **kwargs: nso_module.mock_call(calls, *args, **kwargs)
|
||||||
|
|
||||||
|
data = nso_module.load_fixture('config_empty_data.json')
|
||||||
|
nso_module.set_module_args({
|
||||||
|
'username': 'user', 'password': 'password',
|
||||||
|
'url': 'http://localhost:8080/jsonrpc', 'data': data
|
||||||
|
})
|
||||||
|
self.execute_module(changed=False, changes=[], diffs=[])
|
||||||
|
|
||||||
|
self.assertEqual(0, len(calls))
|
||||||
|
|
||||||
|
@patch('ansible.module_utils.nso.open_url')
|
||||||
|
def test_nso_config_changed(self, open_url_mock):
|
||||||
|
vpn_schema = nso_module.load_fixture('l3vpn_schema.json')
|
||||||
|
l3vpn_schema = nso_module.load_fixture('l3vpn_l3vpn_schema.json')
|
||||||
|
endpoint_schema = nso_module.load_fixture('l3vpn_l3vpn_endpoint_schema.json')
|
||||||
|
changes = nso_module.load_fixture('config_config_changes.json')
|
||||||
|
|
||||||
|
calls = [
|
||||||
|
MockResponse('login', {}, 200, '{}', {'set-cookie': 'id'}),
|
||||||
|
MockResponse('get_system_setting', {'operation': 'version'}, 200, '{"result": "4.5.1"}'),
|
||||||
|
MockResponse('get_module_prefix_map', {}, 200, '{"result": {"l3vpn": "l3vpn"}}'),
|
||||||
|
MockResponse('new_trans', {'mode': 'read'}, 200, '{"result": {"th": 1}}'),
|
||||||
|
MockResponse('get_schema', {'path': '/l3vpn:vpn'}, 200, '{"result": %s}' % (json.dumps(vpn_schema, ))),
|
||||||
|
MockResponse('get_schema', {'path': '/l3vpn:vpn/l3vpn'}, 200, '{"result": %s}' % (json.dumps(l3vpn_schema, ))),
|
||||||
|
MockResponse('exists', {'path': '/l3vpn:vpn/l3vpn{company}'}, 200, '{"result": {"exists": true}}'),
|
||||||
|
MockResponse('get_schema', {'path': '/l3vpn:vpn/l3vpn/endpoint'}, 200, '{"result": %s}' % (json.dumps(endpoint_schema, ))),
|
||||||
|
MockResponse('exists', {'path': '/l3vpn:vpn/l3vpn{company}/endpoint{branch-office1}'}, 200, '{"result": {"exists": false}}'),
|
||||||
|
MockResponse('new_trans', {'mode': 'read_write'}, 200, '{"result": {"th": 2}}'),
|
||||||
|
MockResponse('create', {'path': '/l3vpn:vpn/l3vpn{company}/endpoint{branch-office1}'}, 200, '{"result": {}}'),
|
||||||
|
MockResponse('set_value', {'path': '/l3vpn:vpn/l3vpn{company}/route-distinguisher', 'value': 999}, 200, '{"result": {}}'),
|
||||||
|
MockResponse('set_value', {'path': '/l3vpn:vpn/l3vpn{company}/endpoint{branch-office1}/as-number', 'value': 65101}, 200, '{"result": {}}'),
|
||||||
|
MockResponse('set_value', {'path': '/l3vpn:vpn/l3vpn{company}/endpoint{branch-office1}/bandwidth', 'value': 12000000}, 200, '{"result": {}}'),
|
||||||
|
MockResponse('set_value', {'path': '/l3vpn:vpn/l3vpn{company}/endpoint{branch-office1}/ce-device', 'value': 'ce6'}, 200, '{"result": {}}'),
|
||||||
|
MockResponse('set_value', {'path': '/l3vpn:vpn/l3vpn{company}/endpoint{branch-office1}/ce-interface',
|
||||||
|
'value': 'GigabitEthernet0/12'}, 200, '{"result": {}}'),
|
||||||
|
MockResponse('set_value', {'path': '/l3vpn:vpn/l3vpn{company}/endpoint{branch-office1}/ip-network',
|
||||||
|
'value': '10.10.1.0/24'}, 200, '{"result": {}}'),
|
||||||
|
MockResponse('get_trans_changes', {}, 200, '{"result": %s}' % (json.dumps(changes), )),
|
||||||
|
MockResponse('validate_commit', {}, 200, '{"result": {}}'),
|
||||||
|
MockResponse('commit', {}, 200, '{"result": {}}'),
|
||||||
|
MockResponse('logout', {}, 200, '{"result": {}}'),
|
||||||
|
]
|
||||||
|
open_url_mock.side_effect = lambda *args, **kwargs: nso_module.mock_call(calls, *args, **kwargs)
|
||||||
|
|
||||||
|
data = nso_module.load_fixture('config_config.json')
|
||||||
|
nso_module.set_module_args({
|
||||||
|
'username': 'user', 'password': 'password',
|
||||||
|
'url': 'http://localhost:8080/jsonrpc', 'data': data
|
||||||
|
})
|
||||||
|
self.execute_module(changed=True, changes=[
|
||||||
|
{'path': '/l3vpn:vpn/l3vpn{company}/endpoint{branch-office1}/ce-device', 'type': 'set', 'from': None, 'to': 'ce6'},
|
||||||
|
{'path': '/l3vpn:vpn/l3vpn{company}/endpoint{branch-office1}/ip-network', 'type': 'set', 'from': None, 'to': '10.10.1.0/24'},
|
||||||
|
{'path': '/l3vpn:vpn/l3vpn{company}/endpoint{branch-office1}/as-number', 'type': 'set', 'from': None, 'to': '65101'},
|
||||||
|
{'path': '/l3vpn:vpn/l3vpn{company}/endpoint{branch-office1}/ce-interface', 'type': 'set', 'from': None, 'to': 'GigabitEthernet0/12'},
|
||||||
|
{'path': '/l3vpn:vpn/l3vpn{company}/endpoint{branch-office1}/bandwidth', 'type': 'set', 'from': None, 'to': '12000000'},
|
||||||
|
{'path': '/l3vpn:vpn/l3vpn{company}/endpoint{branch-office1}', 'type': 'create'},
|
||||||
|
], diffs=[])
|
||||||
|
|
||||||
|
self.assertEqual(0, len(calls))
|
Loading…
Reference in a new issue