From f1fe467c22727a354ab5d3410c7c62cc89623f41 Mon Sep 17 00:00:00 2001 From: cnasten Date: Thu, 9 Nov 2017 14:34:41 +0100 Subject: [PATCH] nso_config module for setting configuration in Cisco NSO (#30973) --- .github/BOTMETA.yml | 5 + .../dev_guide/developing_module_utilities.rst | 1 + lib/ansible/module_utils/nso.py | 477 ++++++++++++++++++ lib/ansible/modules/network/nso/__init__.py | 0 lib/ansible/modules/network/nso/nso_config.py | 276 ++++++++++ .../utils/module_docs_fragments/nso.py | 37 ++ test/units/module_utils/test_nso.py | 245 +++++++++ test/units/modules/network/nso/__init__.py | 0 .../network/nso/fixtures/config_config.json | 20 + .../nso/fixtures/config_config_changes.json | 46 ++ .../nso/fixtures/config_empty_data.json | 1 + .../fixtures/l3vpn_l3vpn_endpoint_schema.json | 1 + .../nso/fixtures/l3vpn_l3vpn_schema.json | 1 + .../network/nso/fixtures/l3vpn_schema.json | 1 + test/units/modules/network/nso/nso_module.py | 131 +++++ .../modules/network/nso/test_nso_config.py | 134 +++++ 16 files changed, 1376 insertions(+) create mode 100644 lib/ansible/module_utils/nso.py create mode 100644 lib/ansible/modules/network/nso/__init__.py create mode 100644 lib/ansible/modules/network/nso/nso_config.py create mode 100644 lib/ansible/utils/module_docs_fragments/nso.py create mode 100644 test/units/module_utils/test_nso.py create mode 100644 test/units/modules/network/nso/__init__.py create mode 100644 test/units/modules/network/nso/fixtures/config_config.json create mode 100644 test/units/modules/network/nso/fixtures/config_config_changes.json create mode 100644 test/units/modules/network/nso/fixtures/config_empty_data.json create mode 100644 test/units/modules/network/nso/fixtures/l3vpn_l3vpn_endpoint_schema.json create mode 100644 test/units/modules/network/nso/fixtures/l3vpn_l3vpn_schema.json create mode 100644 test/units/modules/network/nso/fixtures/l3vpn_schema.json create mode 100644 test/units/modules/network/nso/nso_module.py create mode 100644 test/units/modules/network/nso/test_nso_config.py diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 8ea245fc08..24bd11bd97 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -470,6 +470,7 @@ files: $modules/network/netvisor/: $team_netvisor $modules/network/nuage/: pdellaert $modules/network/nxos/: $team_nxos + $modules/network/nso/: $team_nso $modules/network/openvswitch/: ignored: stygstra maintainers: $team_networking @@ -998,6 +999,9 @@ files: $module_utils/network.py: maintainers: $team_networking labels: networking + $module_utils/nso.py: + maintainers: $team_nso + labels: networking $module_utils/nxos.py: maintainers: $team_networking labels: @@ -1205,6 +1209,7 @@ macros: team_netscaler: chiradeep giorgos-nikolopoulos team_netvisor: Qalthos amitsi gundalow privateip team_networking: Qalthos ganeshrn gundalow privateip rcarrillocruz trishnaguha kedarX + team_nso: cmoberg pekdon team_nxos: GGabriele jedelman8 mikewiebe privateip rahushen rcarrillocruz trishnaguha kedarX team_openstack: emonty j2sol juliakreger rcarrillocruz shrews thingee dagnello team_openswitch: Qalthos gundalow privateip diff --git a/docs/docsite/rst/dev_guide/developing_module_utilities.rst b/docs/docsite/rst/dev_guide/developing_module_utilities.rst index 21bc9ede36..32ba57ca82 100644 --- a/docs/docsite/rst/dev_guide/developing_module_utilities.rst +++ b/docs/docsite/rst/dev_guide/developing_module_utilities.rst @@ -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 - netscaler.py - Utilities specifically for the netscaler network modules. - 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 - openstack.py - Utilities for modules that work with Openstack instances. - openswitch.py - Definitions and helper functions for modules that manage OpenSwitch devices diff --git a/lib/ansible/module_utils/nso.py b/lib/ansible/module_utils/nso.py new file mode 100644 index 0000000000..711c3bb6d5 --- /dev/null +++ b/lib/ansible/module_utils/nso.py @@ -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 . +# + + +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'.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)) diff --git a/lib/ansible/modules/network/nso/__init__.py b/lib/ansible/modules/network/nso/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/ansible/modules/network/nso/nso_config.py b/lib/ansible/modules/network/nso/nso_config.py new file mode 100644 index 0000000000..f60ceaf8bb --- /dev/null +++ b/lib/ansible/modules/network/nso/nso_config.py @@ -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 . +# + +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() diff --git a/lib/ansible/utils/module_docs_fragments/nso.py b/lib/ansible/utils/module_docs_fragments/nso.py new file mode 100644 index 0000000000..c262c133d6 --- /dev/null +++ b/lib/ansible/utils/module_docs_fragments/nso.py @@ -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 . +# + + +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 +''' diff --git a/test/units/module_utils/test_nso.py b/test/units/module_utils/test_nso.py new file mode 100644 index 0000000000..7b7c74a6f4 --- /dev/null +++ b/test/units/module_utils/test_nso.py @@ -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 . +# + +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)) diff --git a/test/units/modules/network/nso/__init__.py b/test/units/modules/network/nso/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/units/modules/network/nso/fixtures/config_config.json b/test/units/modules/network/nso/fixtures/config_config.json new file mode 100644 index 0000000000..b7318586b5 --- /dev/null +++ b/test/units/modules/network/nso/fixtures/config_config.json @@ -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 + } + ] + } + ] + } +} diff --git a/test/units/modules/network/nso/fixtures/config_config_changes.json b/test/units/modules/network/nso/fixtures/config_config_changes.json new file mode 100644 index 0000000000..3ef234b7ff --- /dev/null +++ b/test/units/modules/network/nso/fixtures/config_config_changes.json @@ -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" + } + ] +} diff --git a/test/units/modules/network/nso/fixtures/config_empty_data.json b/test/units/modules/network/nso/fixtures/config_empty_data.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/test/units/modules/network/nso/fixtures/config_empty_data.json @@ -0,0 +1 @@ +{} diff --git a/test/units/modules/network/nso/fixtures/l3vpn_l3vpn_endpoint_schema.json b/test/units/modules/network/nso/fixtures/l3vpn_l3vpn_endpoint_schema.json new file mode 100644 index 0000000000..0330aeb9b9 --- /dev/null +++ b/test/units/modules/network/nso/fixtures/l3vpn_l3vpn_endpoint_schema.json @@ -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"]]}} diff --git a/test/units/modules/network/nso/fixtures/l3vpn_l3vpn_schema.json b/test/units/modules/network/nso/fixtures/l3vpn_l3vpn_schema.json new file mode 100644 index 0000000000..2737e7a547 --- /dev/null +++ b/test/units/modules/network/nso/fixtures/l3vpn_l3vpn_schema.json @@ -0,0 +1 @@ +{"meta": {"prefix": "l3vpn", "namespace": "http://com/example/l3vpn", "types": {"http://com/example/l3vpn:t19": [{"list_type": [{"leaf-list": true, "name": "http://com/example/l3vpn:t19"}], "leaf_type": [{"name": "instance-identifier"}]}], "urn:ietf:params:xml:ns:yang:ietf-inet-types:port-number": [{"range": {"value": [["0", "65535"]]}, "name": "urn:ietf:params:xml:ns:yang:ietf-inet-types:port-number"}, {"name": "uint16"}], "http://tail-f.com/ns/ncs:outformat-deep-check-sync": [{"name": "http://tail-f.com/ns/ncs:outformat-deep-check-sync", "enumeration": [{"info": "The CLI config that would have to be applied\nto the device(s) in order for the service to\nbecome in sync with the network.", "label": "cli"}, {"info": "The XML (NETCONF format) that would have to be\napplied to the device(s) in order for the service to\nbecome in sync with the network.", "label": "xml"}, {"info": "Returns if the service is in sync or not.", "label": "boolean"}]}, {"name": "string"}], "http://com/example/l3vpn:t21": [{"list_type": [{"leaf-list": true, "name": "http://com/example/l3vpn:t21"}], "leaf_type": [{"name": "string"}]}], "http://com/example/l3vpn:t15": [{"list_type": [{"leaf-list": true, "name": "http://com/example/l3vpn:t15"}], "leaf_type": [{"name": "string"}]}], "http://com/example/l3vpn:protocol-type": [{"name": "http://com/example/l3vpn:protocol-type", "enumeration": [{"label": "icmp"}, {"label": "igmp"}, {"label": "ipip"}, {"label": "tcp"}, {"label": "egp"}, {"label": "udp"}, {"label": "rsvp"}, {"label": "gre"}, {"label": "esp"}, {"label": "ah"}, {"label": "icmp6"}, {"label": "ospf"}, {"label": "pim"}, {"label": "sctp"}]}, {"name": "string"}], "http://com/example/l3vpn:t17": [{"list_type": [{"leaf-list": true, "name": "http://com/example/l3vpn:t17"}], "leaf_type": [{"name": "instance-identifier"}]}], "http://com/example/l3vpn:t23": [{"list_type": [{"leaf-list": true, "name": "http://com/example/l3vpn:t23"}], "leaf_type": [{"name": "string"}]}], "http://com/example/l3vpn:t11": [{"list_type": [{"leaf-list": true, "name": "http://com/example/l3vpn:t11"}], "leaf_type": [{"name": "instance-identifier"}]}], "http://com/example/l3vpn:t13": [{"list_type": [{"leaf-list": true, "name": "http://com/example/l3vpn:t13"}], "leaf_type": [{"name": "instance-identifier"}]}], "http://com/example/l3vpn:t24": [{"name": "http://com/example/l3vpn:t24", "enumeration": [{"label": "waiting"}, {"label": "executing"}, {"label": "blocking"}, {"label": "blocked"}, {"label": "failed"}, {"label": "admin-cleared"}, {"label": "commit-queue-failed"}]}, {"name": "string"}], "http://tail-f.com/ns/ncs:log-entry-t": [{"info": "This leaf identifies the specific log entry.", "name": "http://tail-f.com/ns/ncs:log-entry-t", "enumeration": [{"label": "device-modified"}, {"label": "service-modified"}]}, {"name": "identityref"}], "http://com/example/l3vpn:t7": [{"name": "http://com/example/l3vpn:t7", "enumeration": [{"label": "async"}, {"label": "timeout"}, {"label": "deleted"}]}, {"name": "string"}], "http://com/example/l3vpn:qos-match-type": [{"union": [[{"name": "ipv4-address-and-prefix-length"}], [{"name": "http://com/example/l3vpn:t2", "enumeration": [{"label": "any"}]}, {"name": "string"}]]}], "http://com/example/l3vpn:t9": [{"list_type": [{"leaf-list": true, "name": "http://com/example/l3vpn:t9"}], "leaf_type": [{"name": "string"}]}], "http://tail-f.com/ns/ncs:outformat4": [{"name": "http://tail-f.com/ns/ncs:outformat4", "enumeration": [{"info": "NCS CLI curly bracket format.", "label": "cli"}, {"info": "NETCONF XML edit-config format, i.e., the edit-config that\nwould be applied locally (at NCS) to get a config\nthat is equal to that of the managed device.", "label": "xml"}, {"info": "The actual data in native format that would be sent to\nthe device", "label": "native"}, {"label": "boolean"}]}, {"name": "string"}], "http://tail-f.com/ns/ncs:log-entry-level-t": [{"info": "Levels used for identifying the severity of an event.\nLevels are organized from least specific to most where\n'all' is least specific and 'error' is most specific.", "name": "http://tail-f.com/ns/ncs:log-entry-level-t", "enumeration": [{"label": "all"}, {"label": "trace"}, {"label": "debug"}, {"label": "info"}, {"label": "warn"}, {"label": "error"}]}, {"name": "string"}], "http://tail-f.com/ns/ncs:outformat2": [{"name": "http://tail-f.com/ns/ncs:outformat2", "enumeration": [{"info": "NCS CLI curly bracket format.", "label": "cli"}, {"info": "NETCONF XML edit-config format, i.e., the edit-config that\nwould be applied locally (at NCS) to get a config\nthat is equal to that of the managed device.", "label": "xml"}]}, {"name": "string"}]}, "keypath": "/l3vpn:vpn/l3vpn"}, "data": {"kind": "list", "leafref_groups": [["used-by-customer-service"]], "min_elements": 0, "name": "l3vpn", "max_elements": "unbounded", "qname": "l3vpn:l3vpn", "children": [{"info": {"string": "Unique service id"}, "kind": "key", "mandatory": true, "name": "name", "qname": "l3vpn:name", "access": {"read": true, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "string"}}, {"info": {"string": "Devices and other services this service modified directly or\nindirectly."}, "kind": "container", "mandatory": true, "name": "modified", "leafrefGroups": [["devices"]], "qname": "l3vpn:modified", "is_config_false_callpoint": true, "access": {"read": true, "create": false, "execute": false, "update": true, "delete": false}, "readonly": true, "leafref_groups": [["devices"]], "config": false, "children": [{"info": {"string": "Devices this service modified directly or indirectly"}, "kind": "leaf-list", "name": "devices", "is_leafref": true, "qname": "l3vpn:devices", "is_config_false_callpoint": true, "access": {"read": true, "create": true, "execute": false, "update": true, "delete": true}, "readonly": true, "leafref_target": "/ncs:devices/device/name", "type": {"namespace": "http://com/example/l3vpn", "name": "t9"}, "config": false}, {"info": {"string": "Services this service modified directly or indirectly"}, "kind": "leaf-list", "name": "services", "type": {"namespace": "http://com/example/l3vpn", "name": "t11"}, "qname": "l3vpn:services", "is_config_false_callpoint": true, "access": {"read": true, "create": true, "execute": false, "update": true, "delete": true}, "readonly": true, "config": false}, {"info": {"string": "Services residing on remote LSA nodes this service\nhas modified directly or indirectly."}, "kind": "leaf-list", "name": "lsa-services", "type": {"namespace": "http://com/example/l3vpn", "name": "t13"}, "qname": "l3vpn:lsa-services", "is_config_false_callpoint": true, "access": {"read": true, "create": true, "execute": false, "update": true, "delete": true}, "readonly": true, "config": false}]}, {"info": {"string": "Devices and other services this service has explicitly\nmodified."}, "kind": "container", "mandatory": true, "name": "directly-modified", "leafrefGroups": [["devices"]], "qname": "l3vpn:directly-modified", "is_config_false_callpoint": true, "access": {"read": true, "create": false, "execute": false, "update": true, "delete": false}, "readonly": true, "leafref_groups": [["devices"]], "config": false, "children": [{"info": {"string": "Devices this service has explicitly modified."}, "kind": "leaf-list", "name": "devices", "is_leafref": true, "qname": "l3vpn:devices", "is_config_false_callpoint": true, "access": {"read": true, "create": true, "execute": false, "update": true, "delete": true}, "readonly": true, "leafref_target": "/ncs:devices/device/name", "type": {"namespace": "http://com/example/l3vpn", "name": "t15"}, "config": false}, {"info": {"string": "Services this service has explicitly modified."}, "kind": "leaf-list", "name": "services", "type": {"namespace": "http://com/example/l3vpn", "name": "t17"}, "qname": "l3vpn:services", "is_config_false_callpoint": true, "access": {"read": true, "create": true, "execute": false, "update": true, "delete": true}, "readonly": true, "config": false}, {"info": {"string": "Services residing on remote LSA nodes this service\nhas explicitly modified."}, "kind": "leaf-list", "name": "lsa-services", "type": {"namespace": "http://com/example/l3vpn", "name": "t19"}, "qname": "l3vpn:lsa-services", "is_config_false_callpoint": true, "access": {"read": true, "create": true, "execute": false, "update": true, "delete": true}, "readonly": true, "config": false}]}, {"info": {"string": "A list of devices this service instance has manipulated"}, "kind": "leaf-list", "name": "device-list", "type": {"namespace": "http://com/example/l3vpn", "name": "t21"}, "qname": "l3vpn:device-list", "is_config_false_callpoint": true, "access": {"read": true, "create": true, "execute": false, "update": true, "delete": true}, "readonly": true, "config": false}, {"info": {"string": "Customer facing services using this service"}, "kind": "leaf-list", "name": "used-by-customer-service", "is_leafref": true, "qname": "l3vpn:used-by-customer-service", "is_config_false_callpoint": true, "access": {"read": true, "create": true, "execute": false, "update": true, "delete": true}, "readonly": true, "leafref_target": "/ncs:services/customer-service/object-id", "type": {"namespace": "http://com/example/l3vpn", "name": "t23"}, "config": false}, {"kind": "container", "mandatory": true, "name": "commit-queue", "qname": "l3vpn:commit-queue", "access": {"read": true, "create": false, "execute": false, "update": true, "delete": false}, "readonly": true, "config": false, "children": [{"kind": "list", "min_elements": 0, "name": "queue-item", "max_elements": "unbounded", "qname": "l3vpn:queue-item", "access": {"read": true, "create": true, "execute": false, "update": true, "delete": true}, "readonly": true, "key": ["id"], "mandatory": true, "config": false, "children": [{"kind": "key", "mandatory": true, "name": "id", "type": {"primitive": true, "name": "uint64"}, "qname": "l3vpn:id", "access": {"read": true, "create": false, "execute": false, "update": true, "delete": false}, "readonly": true, "config": false}, {"kind": "leaf", "name": "status", "type": {"namespace": "http://com/example/l3vpn", "name": "t24"}, "qname": "l3vpn:status", "is_config_false_callpoint": true, "access": {"read": true, "create": true, "execute": false, "update": true, "delete": true}, "readonly": true, "config": false}, {"kind": "list", "leafref_groups": [["name"]], "min_elements": 0, "name": "failed-device", "max_elements": "unbounded", "qname": "l3vpn:failed-device", "children": [{"kind": "key", "mandatory": true, "name": "name", "is_leafref": true, "qname": "l3vpn:name", "access": {"read": true, "create": false, "execute": false, "update": true, "delete": false}, "readonly": true, "leafref_target": "/ncs:devices/device/name", "type": {"primitive": true, "name": "string"}, "config": false}, {"kind": "leaf", "name": "time", "config": false, "qname": "l3vpn:time", "access": {"read": true, "create": true, "execute": false, "update": true, "delete": true}, "readonly": true, "type": {"primitive": true, "name": "date-and-time"}}, {"kind": "leaf", "name": "config-data", "is_cli_preformatted": true, "type": {"primitive": true, "name": "string"}, "qname": "l3vpn:config-data", "access": {"read": true, "create": true, "execute": false, "update": true, "delete": true}, "readonly": true, "config": false}, {"kind": "leaf", "name": "error", "config": false, "qname": "l3vpn:error", "access": {"read": true, "create": true, "execute": false, "update": true, "delete": true}, "readonly": true, "type": {"primitive": true, "name": "string"}}], "access": {"read": true, "create": true, "execute": false, "update": true, "delete": true}, "readonly": true, "key": ["name"], "mandatory": true, "config": false, "leafrefGroups": [["name"]]}, {"access": {"read": false, "create": false, "execute": true, "update": false, "delete": false}, "kind": "action", "mandatory": true, "name": "admin-clear", "qname": "l3vpn:admin-clear"}, {"access": {"read": false, "create": false, "execute": true, "update": false, "delete": false}, "kind": "action", "mandatory": true, "name": "delete", "qname": "l3vpn:delete"}]}, {"access": {"read": false, "create": false, "execute": true, "update": false, "delete": false}, "kind": "action", "mandatory": true, "name": "clear", "qname": "l3vpn:clear"}]}, {"kind": "container", "mandatory": true, "name": "log", "qname": "l3vpn:log", "access": {"read": true, "create": false, "execute": false, "update": true, "delete": false}, "readonly": true, "config": false, "children": [{"kind": "list", "min_elements": 0, "name": "log-entry", "max_elements": "unbounded", "qname": "l3vpn:log-entry", "access": {"read": true, "create": true, "execute": false, "update": true, "delete": true}, "readonly": true, "key": ["when"], "mandatory": true, "config": false, "children": [{"kind": "key", "mandatory": true, "name": "when", "type": {"primitive": true, "name": "date-and-time"}, "qname": "l3vpn:when", "access": {"read": true, "create": false, "execute": false, "update": true, "delete": false}, "readonly": true, "config": false}, {"kind": "leaf", "mandatory": true, "name": "type", "type": {"namespace": "http://tail-f.com/ns/ncs", "name": "log-entry-t"}, "qname": "l3vpn:type", "access": {"read": true, "create": false, "execute": false, "update": true, "delete": false}, "readonly": true, "config": false}, {"kind": "leaf", "mandatory": true, "name": "level", "type": {"namespace": "http://tail-f.com/ns/ncs", "name": "log-entry-level-t"}, "qname": "l3vpn:level", "access": {"read": true, "create": false, "execute": false, "update": true, "delete": false}, "readonly": true, "config": false}, {"kind": "leaf", "name": "message", "config": false, "qname": "l3vpn:message", "access": {"read": true, "create": true, "execute": false, "update": true, "delete": true}, "readonly": true, "type": {"primitive": true, "name": "string"}}]}, {"info": {"string": "Remove log entries"}, "kind": "action", "mandatory": true, "name": "purge", "qname": "l3vpn:purge", "access": {"read": false, "create": false, "execute": true, "update": false, "delete": false}}]}, {"kind": "leaf", "mandatory": true, "name": "route-distinguisher", "qname": "l3vpn:route-distinguisher", "access": {"read": true, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "uint32"}}, {"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"]]}, {"kind": "container", "mandatory": true, "name": "qos", "leafrefGroups": [["qos-policy"]], "qname": "l3vpn:qos", "access": {"read": true, "create": false, "execute": false, "update": true, "delete": false}, "leafref_groups": [["qos-policy"]], "children": [{"kind": "leaf", "name": "qos-policy", "type": {"primitive": true, "name": "string"}, "qname": "l3vpn:qos-policy", "access": {"read": true, "create": true, "execute": false, "update": true, "delete": true}, "leafref_target": "/l3vpn:qos/qos-policy/name", "is_leafref": true}, {"kind": "list", "leafref_groups": [["qos-class"]], "min_elements": 0, "name": "custom-qos-match", "max_elements": "unbounded", "qname": "l3vpn:custom-qos-match", "children": [{"kind": "key", "mandatory": true, "name": "name", "qname": "l3vpn:name", "access": {"read": true, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "string"}}, {"kind": "leaf", "mandatory": true, "name": "qos-class", "type": {"primitive": true, "name": "string"}, "qname": "l3vpn:qos-class", "access": {"read": true, "create": false, "execute": false, "update": true, "delete": false}, "leafref_target": "/l3vpn:qos/qos-class/name", "is_leafref": true}, {"access": {"read": true, "create": true, "execute": false, "update": true, "delete": true}, "kind": "leaf", "type": {"namespace": "http://com/example/l3vpn", "name": "qos-match-type"}, "name": "source-ip", "qname": "l3vpn:source-ip"}, {"access": {"read": true, "create": true, "execute": false, "update": true, "delete": true}, "kind": "leaf", "type": {"namespace": "http://com/example/l3vpn", "name": "qos-match-type"}, "name": "destination-ip", "qname": "l3vpn:destination-ip"}, {"info": {"string": "Destination IP port"}, "kind": "leaf", "name": "port-start", "qname": "l3vpn:port-start", "access": {"read": true, "create": true, "execute": false, "update": true, "delete": true}, "type": {"namespace": "urn:ietf:params:xml:ns:yang:ietf-inet-types", "name": "port-number"}}, {"info": {"string": "Destination IP port"}, "kind": "leaf", "name": "port-end", "qname": "l3vpn:port-end", "access": {"read": true, "create": true, "execute": false, "update": true, "delete": true}, "type": {"namespace": "urn:ietf:params:xml:ns:yang:ietf-inet-types", "name": "port-number"}}, {"info": {"string": "Source IP protocol"}, "kind": "leaf", "name": "protocol", "qname": "l3vpn:protocol", "access": {"read": true, "create": true, "execute": false, "update": true, "delete": true}, "type": {"namespace": "http://com/example/l3vpn", "name": "protocol-type"}}], "access": {"read": true, "create": true, "execute": false, "update": true, "delete": true}, "key": ["name"], "mandatory": true, "leafrefGroups": [["qos-class"]]}]}, {"info": {"string": "Check if device config is according to the service"}, "kind": "action", "mandatory": true, "name": "check-sync", "qname": "l3vpn:check-sync", "access": {"read": false, "create": false, "execute": true, "update": false, "delete": false}, "children": [{"kind": "leaf", "is_action_input": true, "name": "outformat", "default": "boolean", "qname": "l3vpn:outformat", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}, "type": {"namespace": "http://tail-f.com/ns/ncs", "name": "outformat4"}}, {"default": "deep", "kind": "choice", "cases": [{"kind": "case", "name": "deep", "children": [{"kind": "leaf", "is_action_input": true, "name": "deep", "qname": "l3vpn:deep", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "empty"}}]}, {"kind": "case", "name": "shallow", "children": [{"kind": "leaf", "is_action_input": true, "name": "shallow", "qname": "l3vpn:shallow", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "empty"}}]}], "name": "depth"}, {"info": {"string": "Return list only contains negatives"}, "kind": "leaf", "is_action_input": true, "name": "suppress-positive-result", "qname": "l3vpn:suppress-positive-result", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "empty"}}, {"kind": "choice", "cases": [{"kind": "case", "name": "use-lsa", "children": [{"kind": "leaf", "is_action_input": true, "name": "use-lsa", "qname": "l3vpn:use-lsa", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "empty"}}]}, {"kind": "case", "name": "no-lsa", "children": [{"kind": "leaf", "is_action_input": true, "name": "no-lsa", "qname": "l3vpn:no-lsa", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "empty"}}]}], "name": "choice-lsa"}, {"kind": "choice", "cases": [{"kind": "case", "name": "in-sync", "children": [{"kind": "leaf", "name": "in-sync", "qname": "l3vpn:in-sync", "access": {"read": false, "create": false, "execute": false, "update": false, "delete": false}, "type": {"primitive": true, "name": "boolean"}, "is_action_output": true}]}, {"kind": "case", "name": "case-xml", "children": [{"kind": "container", "mandatory": true, "name": "result-xml", "qname": "l3vpn:result-xml", "access": {"read": false, "create": false, "execute": false, "update": false, "delete": false}, "is_action_output": true}]}, {"kind": "case", "name": "case-cli", "children": [{"kind": "container", "mandatory": true, "name": "cli", "qname": "l3vpn:cli", "access": {"read": false, "create": false, "execute": false, "update": false, "delete": false}, "is_action_output": true}]}, {"kind": "case", "name": "case-native", "children": [{"kind": "container", "mandatory": true, "name": "native", "qname": "l3vpn:native", "access": {"read": false, "create": false, "execute": false, "update": false, "delete": false}, "is_action_output": true}]}], "name": "outformat"}]}, {"info": {"string": "Check if device config is according to the service"}, "kind": "action", "mandatory": true, "name": "deep-check-sync", "qname": "l3vpn:deep-check-sync", "access": {"read": false, "create": false, "execute": true, "update": false, "delete": false}, "children": [{"kind": "leaf", "is_action_input": true, "name": "outformat", "default": "boolean", "qname": "l3vpn:outformat", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}, "type": {"namespace": "http://tail-f.com/ns/ncs", "name": "outformat-deep-check-sync"}}, {"info": {"string": "Return list only contains negatives"}, "kind": "leaf", "is_action_input": true, "name": "suppress-positive-result", "qname": "l3vpn:suppress-positive-result", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "empty"}}, {"kind": "choice", "cases": [{"kind": "case", "name": "use-lsa", "children": [{"kind": "leaf", "is_action_input": true, "name": "use-lsa", "qname": "l3vpn:use-lsa", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "empty"}}]}, {"kind": "case", "name": "no-lsa", "children": [{"kind": "leaf", "is_action_input": true, "name": "no-lsa", "qname": "l3vpn:no-lsa", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "empty"}}]}], "name": "choice-lsa"}, {"kind": "choice", "cases": [{"kind": "case", "name": "case-xml", "children": [{"kind": "container", "mandatory": true, "name": "result-xml", "qname": "l3vpn:result-xml", "access": {"read": false, "create": false, "execute": false, "update": false, "delete": false}, "is_action_output": true}]}, {"kind": "case", "name": "case-cli", "children": [{"kind": "container", "mandatory": true, "name": "cli", "qname": "l3vpn:cli", "access": {"read": false, "create": false, "execute": false, "update": false, "delete": false}, "is_action_output": true}]}, {"kind": "case", "name": "case-sync", "children": [{"kind": "container", "mandatory": true, "name": "sync-result", "qname": "l3vpn:sync-result", "access": {"read": false, "create": false, "execute": false, "update": false, "delete": false}, "is_action_output": true}]}], "name": "outformat"}]}, {"info": {"string": "Run/Dryrun the service logic again"}, "kind": "action", "mandatory": true, "name": "re-deploy", "qname": "l3vpn:re-deploy", "access": {"read": false, "create": false, "execute": true, "update": false, "delete": false}, "children": [{"kind": "container", "is_action_input": true, "name": "dry-run", "presence": true, "qname": "l3vpn:dry-run", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}}, {"kind": "leaf", "is_action_input": true, "name": "no-revision-drop", "qname": "l3vpn:no-revision-drop", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "empty"}}, {"kind": "leaf", "is_action_input": true, "name": "no-networking", "qname": "l3vpn:no-networking", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "empty"}}, {"kind": "choice", "cases": [{"kind": "case", "name": "no-overwrite", "children": [{"kind": "leaf", "is_action_input": true, "name": "no-overwrite", "qname": "l3vpn:no-overwrite", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "empty"}}]}, {"kind": "case", "name": "no-out-of-sync-check", "children": [{"kind": "leaf", "is_action_input": true, "name": "no-out-of-sync-check", "qname": "l3vpn:no-out-of-sync-check", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "empty"}}]}], "name": "choice-sync-check"}, {"kind": "container", "is_action_input": true, "name": "commit-queue", "presence": true, "qname": "l3vpn:commit-queue", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}}, {"kind": "choice", "cases": [{"kind": "case", "name": "use-lsa", "children": [{"kind": "leaf", "is_action_input": true, "name": "use-lsa", "qname": "l3vpn:use-lsa", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "empty"}}]}, {"kind": "case", "name": "no-lsa", "children": [{"kind": "leaf", "is_action_input": true, "name": "no-lsa", "qname": "l3vpn:no-lsa", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "empty"}}]}], "name": "choice-lsa"}, {"default": "deep", "kind": "choice", "cases": [{"kind": "case", "name": "deep", "children": [{"kind": "leaf", "is_action_input": true, "name": "deep", "qname": "l3vpn:deep", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "empty"}}]}, {"kind": "case", "name": "shallow", "children": [{"kind": "leaf", "is_action_input": true, "name": "shallow", "qname": "l3vpn:shallow", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "empty"}}]}], "name": "depth"}, {"kind": "container", "is_action_input": true, "name": "reconcile", "presence": true, "qname": "l3vpn:reconcile", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}}, {"kind": "choice", "cases": [{"kind": "case", "name": "case-xml", "children": [{"kind": "container", "mandatory": true, "name": "result-xml", "qname": "l3vpn:result-xml", "access": {"read": false, "create": false, "execute": false, "update": false, "delete": false}, "is_action_output": true}]}, {"kind": "case", "name": "case-cli", "children": [{"kind": "container", "mandatory": true, "name": "cli", "qname": "l3vpn:cli", "access": {"read": false, "create": false, "execute": false, "update": false, "delete": false}, "is_action_output": true}]}, {"kind": "case", "name": "case-native", "children": [{"kind": "container", "mandatory": true, "name": "native", "qname": "l3vpn:native", "access": {"read": false, "create": false, "execute": false, "update": false, "delete": false}, "is_action_output": true}]}], "name": "outformat"}, {"kind": "container", "mandatory": true, "name": "commit-queue", "qname": "l3vpn:commit-queue", "access": {"read": false, "create": false, "execute": false, "update": false, "delete": false}, "is_action_output": true}, {"kind": "leaf", "name": "id", "type": {"primitive": true, "name": "uint64"}, "qname": "l3vpn:id", "access": {"read": false, "create": false, "execute": false, "update": false, "delete": false}, "leafref_target": "/ncs:devices/commit-queue/queue-item/id", "is_leafref": true, "is_action_output": true}, {"kind": "leaf", "name": "tag", "qname": "l3vpn:tag", "access": {"read": false, "create": false, "execute": false, "update": false, "delete": false}, "type": {"primitive": true, "name": "string"}, "is_action_output": true}, {"kind": "leaf", "name": "status", "qname": "l3vpn:status", "access": {"read": false, "create": false, "execute": false, "update": false, "delete": false}, "type": {"namespace": "http://com/example/l3vpn", "name": "t7"}, "is_action_output": true}]}, {"info": {"string": "Reactive redeploy of service logic"}, "kind": "action", "mandatory": true, "name": "reactive-re-deploy", "qname": "l3vpn:reactive-re-deploy", "access": {"read": false, "create": false, "execute": true, "update": false, "delete": false}, "children": [{"kind": "leaf", "name": "id", "type": {"primitive": true, "name": "uint64"}, "qname": "l3vpn:id", "access": {"read": false, "create": false, "execute": false, "update": false, "delete": false}, "leafref_target": "/ncs:devices/commit-queue/queue-item/id", "is_leafref": true, "is_action_output": true}, {"kind": "leaf", "name": "tag", "qname": "l3vpn:tag", "access": {"read": false, "create": false, "execute": false, "update": false, "delete": false}, "type": {"primitive": true, "name": "string"}, "is_action_output": true}, {"kind": "leaf", "name": "status", "qname": "l3vpn:status", "access": {"read": false, "create": false, "execute": false, "update": false, "delete": false}, "type": {"namespace": "http://com/example/l3vpn", "name": "t7"}, "is_action_output": true}]}, {"info": {"string": "Touch a service"}, "kind": "action", "mandatory": true, "name": "touch", "qname": "l3vpn:touch", "access": {"read": false, "create": false, "execute": true, "update": false, "delete": false}, "children": []}, {"info": {"string": "Get the data this service created"}, "kind": "action", "mandatory": true, "name": "get-modifications", "qname": "l3vpn:get-modifications", "access": {"read": false, "create": false, "execute": true, "update": false, "delete": false}, "children": [{"kind": "leaf", "is_action_input": true, "name": "outformat", "qname": "l3vpn:outformat", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}, "type": {"namespace": "http://tail-f.com/ns/ncs", "name": "outformat2"}}, {"kind": "leaf", "is_action_input": true, "name": "reverse", "qname": "l3vpn:reverse", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "empty"}}, {"default": "deep", "kind": "choice", "cases": [{"kind": "case", "name": "deep", "children": [{"kind": "leaf", "is_action_input": true, "name": "deep", "qname": "l3vpn:deep", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "empty"}}]}, {"kind": "case", "name": "shallow", "children": [{"kind": "leaf", "is_action_input": true, "name": "shallow", "qname": "l3vpn:shallow", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "empty"}}]}], "name": "depth"}, {"kind": "choice", "cases": [{"kind": "case", "name": "use-lsa", "children": [{"kind": "leaf", "is_action_input": true, "name": "use-lsa", "qname": "l3vpn:use-lsa", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "empty"}}]}, {"kind": "case", "name": "no-lsa", "children": [{"kind": "leaf", "is_action_input": true, "name": "no-lsa", "qname": "l3vpn:no-lsa", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "empty"}}]}], "name": "choice-lsa"}, {"kind": "choice", "cases": [{"kind": "case", "name": "case-xml", "children": [{"kind": "container", "mandatory": true, "name": "result-xml", "qname": "l3vpn:result-xml", "access": {"read": false, "create": false, "execute": false, "update": false, "delete": false}, "is_action_output": true}]}, {"kind": "case", "name": "case-cli", "children": [{"kind": "container", "mandatory": true, "name": "cli", "qname": "l3vpn:cli", "access": {"read": false, "create": false, "execute": false, "update": false, "delete": false}, "is_action_output": true}]}], "name": "outformat"}]}, {"info": {"string": "Undo the effects of this service"}, "kind": "action", "mandatory": true, "name": "un-deploy", "qname": "l3vpn:un-deploy", "access": {"read": false, "create": false, "execute": true, "update": false, "delete": false}, "children": [{"kind": "container", "is_action_input": true, "name": "dry-run", "presence": true, "qname": "l3vpn:dry-run", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}}, {"kind": "leaf", "is_action_input": true, "name": "no-revision-drop", "qname": "l3vpn:no-revision-drop", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "empty"}}, {"kind": "leaf", "is_action_input": true, "name": "no-networking", "qname": "l3vpn:no-networking", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "empty"}}, {"kind": "choice", "cases": [{"kind": "case", "name": "no-overwrite", "children": [{"kind": "leaf", "is_action_input": true, "name": "no-overwrite", "qname": "l3vpn:no-overwrite", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "empty"}}]}, {"kind": "case", "name": "no-out-of-sync-check", "children": [{"kind": "leaf", "is_action_input": true, "name": "no-out-of-sync-check", "qname": "l3vpn:no-out-of-sync-check", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "empty"}}]}], "name": "choice-sync-check"}, {"kind": "container", "is_action_input": true, "name": "commit-queue", "presence": true, "qname": "l3vpn:commit-queue", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}}, {"kind": "choice", "cases": [{"kind": "case", "name": "use-lsa", "children": [{"kind": "leaf", "is_action_input": true, "name": "use-lsa", "qname": "l3vpn:use-lsa", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "empty"}}]}, {"kind": "case", "name": "no-lsa", "children": [{"kind": "leaf", "is_action_input": true, "name": "no-lsa", "qname": "l3vpn:no-lsa", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "empty"}}]}], "name": "choice-lsa"}, {"kind": "leaf", "is_action_input": true, "name": "ignore-refcount", "qname": "l3vpn:ignore-refcount", "access": {"read": false, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "empty"}}, {"kind": "choice", "cases": [{"kind": "case", "name": "case-xml", "children": [{"kind": "container", "mandatory": true, "name": "result-xml", "qname": "l3vpn:result-xml", "access": {"read": false, "create": false, "execute": false, "update": false, "delete": false}, "is_action_output": true}]}, {"kind": "case", "name": "case-cli", "children": [{"kind": "container", "mandatory": true, "name": "cli", "qname": "l3vpn:cli", "access": {"read": false, "create": false, "execute": false, "update": false, "delete": false}, "is_action_output": true}]}, {"kind": "case", "name": "case-native", "children": [{"kind": "container", "mandatory": true, "name": "native", "qname": "l3vpn:native", "access": {"read": false, "create": false, "execute": false, "update": false, "delete": false}, "is_action_output": true}]}], "name": "outformat"}, {"kind": "container", "mandatory": true, "name": "commit-queue", "qname": "l3vpn:commit-queue", "access": {"read": false, "create": false, "execute": false, "update": false, "delete": false}, "is_action_output": true}, {"kind": "leaf", "name": "id", "type": {"primitive": true, "name": "uint64"}, "qname": "l3vpn:id", "access": {"read": false, "create": false, "execute": false, "update": false, "delete": false}, "leafref_target": "/ncs:devices/commit-queue/queue-item/id", "is_leafref": true, "is_action_output": true}, {"kind": "leaf", "name": "tag", "qname": "l3vpn:tag", "access": {"read": false, "create": false, "execute": false, "update": false, "delete": false}, "type": {"primitive": true, "name": "string"}, "is_action_output": true}, {"kind": "leaf", "name": "status", "qname": "l3vpn:status", "access": {"read": false, "create": false, "execute": false, "update": false, "delete": false}, "type": {"namespace": "http://com/example/l3vpn", "name": "t7"}, "is_action_output": true}]}], "access": {"read": true, "create": true, "execute": false, "update": true, "delete": true}, "key": ["name"], "mandatory": true, "leafrefGroups": [["used-by-customer-service"]]}} diff --git a/test/units/modules/network/nso/fixtures/l3vpn_schema.json b/test/units/modules/network/nso/fixtures/l3vpn_schema.json new file mode 100644 index 0000000000..0e7e370382 --- /dev/null +++ b/test/units/modules/network/nso/fixtures/l3vpn_schema.json @@ -0,0 +1 @@ +{"meta": {"prefix": "l3vpn", "namespace": "http://com/example/l3vpn", "types": {"http://com/example/l3vpn:t21": [{"list_type": [{"leaf-list": true, "name": "http://com/example/l3vpn:t21"}], "leaf_type": [{"name": "string"}]}], "http://com/example/l3vpn:t23": [{"list_type": [{"leaf-list": true, "name": "http://com/example/l3vpn:t23"}], "leaf_type": [{"name": "string"}]}]}, "keypath": "/l3vpn:vpn"}, "data": {"kind": "container", "mandatory": true, "name": "vpn", "qname": "l3vpn:vpn", "access": {"read": true, "create": false, "execute": false, "update": true, "delete": false}, "children": [{"kind": "list", "leafref_groups": [["used-by-customer-service"]], "min_elements": 0, "name": "l3vpn", "max_elements": "unbounded", "qname": "l3vpn:l3vpn", "children": [{"info": {"string": "Unique service id"}, "kind": "key", "mandatory": true, "name": "name", "qname": "l3vpn:name", "access": {"read": true, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "string"}}, {"info": {"string": "Devices and other services this service modified directly or\nindirectly."}, "kind": "container", "mandatory": true, "name": "modified", "leafrefGroups": [["devices"]], "qname": "l3vpn:modified", "is_config_false_callpoint": true, "access": {"read": true, "create": false, "execute": false, "update": true, "delete": false}, "readonly": true, "leafref_groups": [["devices"]], "config": false}, {"info": {"string": "Devices and other services this service has explicitly\nmodified."}, "kind": "container", "mandatory": true, "name": "directly-modified", "leafrefGroups": [["devices"]], "qname": "l3vpn:directly-modified", "is_config_false_callpoint": true, "access": {"read": true, "create": false, "execute": false, "update": true, "delete": false}, "readonly": true, "leafref_groups": [["devices"]], "config": false}, {"info": {"string": "A list of devices this service instance has manipulated"}, "kind": "leaf-list", "name": "device-list", "type": {"namespace": "http://com/example/l3vpn", "name": "t21"}, "qname": "l3vpn:device-list", "is_config_false_callpoint": true, "access": {"read": true, "create": true, "execute": false, "update": true, "delete": true}, "readonly": true, "config": false}, {"info": {"string": "Customer facing services using this service"}, "kind": "leaf-list", "name": "used-by-customer-service", "is_leafref": true, "qname": "l3vpn:used-by-customer-service", "is_config_false_callpoint": true, "access": {"read": true, "create": true, "execute": false, "update": true, "delete": true}, "readonly": true, "leafref_target": "/ncs:services/customer-service/object-id", "type": {"namespace": "http://com/example/l3vpn", "name": "t23"}, "config": false}, {"kind": "container", "mandatory": true, "name": "commit-queue", "qname": "l3vpn:commit-queue", "access": {"read": true, "create": false, "execute": false, "update": true, "delete": false}, "readonly": true, "config": false}, {"kind": "container", "mandatory": true, "name": "log", "qname": "l3vpn:log", "access": {"read": true, "create": false, "execute": false, "update": true, "delete": false}, "readonly": true, "config": false}, {"kind": "leaf", "mandatory": true, "name": "route-distinguisher", "qname": "l3vpn:route-distinguisher", "access": {"read": true, "create": false, "execute": false, "update": true, "delete": false}, "type": {"primitive": true, "name": "uint32"}}, {"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"]]}, {"kind": "container", "mandatory": true, "name": "qos", "leafrefGroups": [["qos-policy"]], "qname": "l3vpn:qos", "access": {"read": true, "create": false, "execute": false, "update": true, "delete": false}, "leafref_groups": [["qos-policy"]]}, {"info": {"string": "Check if device config is according to the service"}, "kind": "action", "mandatory": true, "name": "check-sync", "qname": "l3vpn:check-sync", "access": {"read": false, "create": false, "execute": true, "update": false, "delete": false}}, {"info": {"string": "Check if device config is according to the service"}, "kind": "action", "mandatory": true, "name": "deep-check-sync", "qname": "l3vpn:deep-check-sync", "access": {"read": false, "create": false, "execute": true, "update": false, "delete": false}}, {"info": {"string": "Run/Dryrun the service logic again"}, "kind": "action", "mandatory": true, "name": "re-deploy", "qname": "l3vpn:re-deploy", "access": {"read": false, "create": false, "execute": true, "update": false, "delete": false}}, {"info": {"string": "Reactive redeploy of service logic"}, "kind": "action", "mandatory": true, "name": "reactive-re-deploy", "qname": "l3vpn:reactive-re-deploy", "access": {"read": false, "create": false, "execute": true, "update": false, "delete": false}}, {"info": {"string": "Touch a service"}, "kind": "action", "mandatory": true, "name": "touch", "qname": "l3vpn:touch", "access": {"read": false, "create": false, "execute": true, "update": false, "delete": false}}, {"info": {"string": "Get the data this service created"}, "kind": "action", "mandatory": true, "name": "get-modifications", "qname": "l3vpn:get-modifications", "access": {"read": false, "create": false, "execute": true, "update": false, "delete": false}}, {"info": {"string": "Undo the effects of this service"}, "kind": "action", "mandatory": true, "name": "un-deploy", "qname": "l3vpn:un-deploy", "access": {"read": false, "create": false, "execute": true, "update": false, "delete": false}}], "access": {"read": true, "create": true, "execute": false, "update": true, "delete": true}, "key": ["name"], "mandatory": true, "leafrefGroups": [["used-by-customer-service"]]}]}} diff --git a/test/units/modules/network/nso/nso_module.py b/test/units/modules/network/nso/nso_module.py new file mode 100644 index 0000000000..44ce5d5049 --- /dev/null +++ b/test/units/modules/network/nso/nso_module.py @@ -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 . + +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 diff --git a/test/units/modules/network/nso/test_nso_config.py b/test/units/modules/network/nso/test_nso_config.py new file mode 100644 index 0000000000..546688a0ce --- /dev/null +++ b/test/units/modules/network/nso/test_nso_config.py @@ -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 . + +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))