diff options
-rw-r--r-- | simulator-cli/.gitignore | 7 | ||||
-rw-r--r-- | simulator-cli/README.md | 309 | ||||
-rw-r--r-- | simulator-cli/cli/__init__.py | 19 | ||||
-rw-r--r-- | simulator-cli/cli/client/__init__.py | 19 | ||||
-rw-r--r-- | simulator-cli/cli/client/tailf_client.py | 59 | ||||
-rw-r--r-- | simulator-cli/cli/data/logging.ini | 20 | ||||
-rw-r--r-- | simulator-cli/cli/netconf_server.py | 278 | ||||
-rw-r--r-- | simulator-cli/cli/nf_simulator.py | 374 | ||||
-rw-r--r-- | simulator-cli/requirements.txt | 24 | ||||
-rw-r--r-- | simulator-cli/setup.py | 34 | ||||
-rw-r--r-- | simulator-cli/tests/__init__.py | 19 | ||||
-rw-r--r-- | simulator-cli/tests/resources/notification.json | 17 | ||||
-rw-r--r-- | simulator-cli/tests/test_netconf_server.py | 165 | ||||
-rw-r--r-- | simulator-cli/tests/test_nf_simulator.py | 270 | ||||
-rw-r--r-- | simulator-cli/tests/test_tailf_client.py | 47 | ||||
-rw-r--r-- | simulator-cli/tox.ini | 10 |
16 files changed, 1671 insertions, 0 deletions
diff --git a/simulator-cli/.gitignore b/simulator-cli/.gitignore new file mode 100644 index 0000000..96a29d6 --- /dev/null +++ b/simulator-cli/.gitignore @@ -0,0 +1,7 @@ +**/*.iml +**/.idea +**/target +**/__pycache__ +build/** +dist/** +pnf_simulator_cli.egg-info/**
\ No newline at end of file diff --git a/simulator-cli/README.md b/simulator-cli/README.md new file mode 100644 index 0000000..0202cb2 --- /dev/null +++ b/simulator-cli/README.md @@ -0,0 +1,309 @@ +## NF SIMULATOR/NETCONF SERVER CLI + +### Overview +Anytime you want to see a basic usage of a tool, you can run fully descriptive help using command: +``` +./{tool_name}.py -h # --help argument is also acceptable +``` + +#### NF Simulator CLI +NF Simulator CLI provides command line interface to remotely interact with running NF Simulator. + +Using the NF Simulator CLI user is able to trigger events, retrieve simulator's configuration and change default VES url stored +inside simulator. + +#### Netconf Server CLI +Dedicated tool to help with management of the Netconf Server is also available. + +Using the Netconf Server CLI user is able to retrieve simulator's cm history stored inside simulator as well as open the live session to actively listen for new configuration changes. + +### Requirements and installation +Requirements +* Python > 3.5 + +Installation: +* Go to directory containing setup.py and invoke `python setup.py install` +* Go to cli directory +* Add executable privilege to nf_simulator.py and netconf_server.py (for example `chmod +x <path_to_nf_simulator.py>`) + +### Development and testing +To run unit tests for NF and Netconf servers cli execute `tox`. + +To develop unit tests run `tox`, then `source .tox/pytest/bin/activate` and after each code change run `pytest`. + +### Nf simulator +#### Usage +* [send](#send-action) +* [configure](#configure-action) +* [get-config](#get-config-action) +* [template](#template-action) +* [filter](#filter-templates-action) + + +#### Help +Invoke `nf_simulator.py [send|configure|get-config] -h` to display help. + +##### Send Action +Send action allows user to trigger sending events from Simulator to VES Collector. + +*sending repeating events backed by template persisted in db +`usage: nf_simulator.py send template [-h] --address ADDRESS --name NAME + [--patch PATCH] [--repeats REPEATS] + [--interval INTERVAL] + [--ves-server-url VES_SERVER_URL] [--verbose] +` + +Parameters +` --address ADDRESS` `IP address of simulator` +` --name NAME` `Name of template file which should be used as a base for event. + Cannot be used simultaneously with parameter: event.` +` --patch PATCH` `Json which should be merged into template to override parameters. + Acceptable format: valid json wrapped using single quotes (example:'{"abc":1}'). + Cannot be used simultaneously with parameter: event.` +` --repeats REPEATS` `Number of events to be send` +` --interval INTERVAL` `Interval between two consecutive events (in seconds)` +` --ves-server-url VES_SERVER_URL` `Well-formed URL which will override current VES endpoint stored in simulator's DB` +` --verbose` `Displays additional logs` + + +*sending event only once by passing path to file with complete event +`usage: nf_simulator.py send event [-h] --address ADDRESS --filepath FILEPATH + [--ves-server-url VES_SERVER_URL] [--verbose] +` +Parameters +` --address ADDRESS` `IP address of simulator` +` --filepath FILEPATH` `Path to file with full, legitimate event that is to be send directly to VES only once. + This event is not associated with template and will not be persisted in db. + Cannot be used simultaneously with parameters: template and patch.` +` --ves-server-url VES_SERVER_URL` `Well-formed URL which will override current VES endpoint stored in simulator's DB` +` --verbose` `Displays additional logs` + +example content of file with complete event: +``` +{ + "commonEventHeader": { + "eventId": "#Timestamp", + "sourceName": "#Increment", + "version": 3.0 + } +} +``` + +##### Configure Action +Configure action allows user to change Simulator's configuration (VES Server URL) +`usage: nf_simulator.py configure [-h] --address ADDRESS --ves-server-url + VES_SERVER_URL [--verbose] +` + +Parameters + +` --address ADDRESS` `IP address of simulator` +` --ves-server-url VES_SERVER_URL` `Well-formed URL which should be set as a default VES Server URL in simulator` +` --verbose` `Displays additional logs` + +##### Get Config Action +Get Config action allows user to retrieve actual Simulator's configuration +`usage: nf_simulator.py get-config [-h] --address ADDRESS [--verbose] ` + +Parameters + +`--address ADDRESS` `IP address of simulator` +`--verbose` `Displays additional logs` + +##### Template Action +Template action allows user to: +* retrieve a single template by name +* list all available templates. +* upload template to NF Simulator (can overwrite existing template) + +`usage: nf_simulator.py template [-h] + (--list | --get-content NAME | --upload FILENAME) + [--override] --address ADDRESS [--verbose]` + +Parameters + +`--get-content NAME` `Gets the template by name` +`--list` `List all templates` +`--upload FILENAME [--override]` `Uploads the template given as FILENAME file. Optionally overrides any exisitng templates with matching filename` +`--address ADDRESS` `IP address of simulator` +`--verbose` `Displays additional logs` + +#### Filter Templates Action +Filter template action allows to search through templates in order to find names of those that satisfy given criteria. +Criteria are passed in JSON format, as key-values pairs. Relation between pairs with criteria is AND (all conditions must be satisfied by template to have it returned). +No searching for null values is supported. +Search expression must be valid JSON, thus no duplicate keys are allowed - user could specify the same parameter multiple times, but only last occurrence will be applied to query. + + +`usage: nf_simulator.py filter [-h] + --criteria CRITERIA --address ADDRESS [--verbose]` + +Parameters +`--criteria CRITERIA` `Json with criteria as key-value pairs, where values can be one of following data types: string, integer, double, boolean. + Acceptable format: valid json wrapped using single quotes (example:'{"searchedInt":1}'). + Cannot be used simultaneously with parameter: event.` +`--address ADDRESS` `IP address of simulator` +`--verbose` `Displays additional logs` + + +### Netconf server +#### Usage +* [load-model](#load-model-action) +* [delete-model](#delete-model-action) +* [get-config](#get-config-action) +* [edit-config](#edit-config-action) +* [tailf](#tailf-action) +* [less](#less-action) +* [cm-history](#cm-history-action) + +#### Help +Invoke `netconf_server.py [tailf|less|cm-history] -h` to display help. + + +#### Load-model action +Loads to netconf server new YANG model that corresponds with schema passed as yang-model parameter, +assigns name specified in module-name and initializes model with startup configuration passed in config file. +`usage: netconf_server.py load-module [-h] --address ADDRESS ---module-name MODULE_NAME --yang-model YANG_MODEL_FILEPATH --config <XML_CONFIG_FILEPATH> [--verbose]` + +example YANG schema (file content for YANG_MODEL) +``` +Response status: 200 +module nf-simulator { + namespace "http://nokia.com/nf-simulator"; + prefix config; + container config { + config true; + leaf itemValue1 {type uint32;} + leaf itemValue2 {type uint32;} + leaf itemValue3 {type uint32;} + leaf-list allow-user { + type string; + ordered-by user; + description "A sample list of user names."; + } + } +} +``` + +example startup configuration (file content of XML_CONFIG) +``` +<config xmlns="http://nokia.com/nf-simulator"> + <itemValue1>100</itemValue1> + <itemValue2>200</itemValue2> + <itemValue3>300</itemValue3> +</config> +``` + + +example output (without verbose flag): +``` +Response status: 200 +Successfully started +``` + +#### Delete-model action +Deletes a YANG model loaded in the netconf server. + +`usage: netconf_server.py delete-model [-h] --address ADDRESS --model-name + MODEL_NAME [--verbose]` + +Example output (without verbose flag): +``` +Response status: 200 +Successfully deleted +``` + +#### Get-config Action +Returns active running configurations. +By default it returns all running configurations. To retrieve one specific configuration (represented by _/'module_name':'container'_ ) user needs to pass module-name and container. +Example: +` +netconf_server.py get-config --address localhost --module-name nf-simulator --container config +` + + +`usage: netconf_server.py get-config [-h] --address ADDRESS [--verbose] [--module-name MODULE-NAME] [--container CONTAINER]` + +example output (without verbose flag): +``` +Response status: 200 +<config xmlns="http://nokia.com/nf-simulator" xmlns:nc="urn:ietf:params:xml:ns:netconf:base:1.0"> + <itemValue1>2781</itemValue1> + <itemValue2>3782</itemValue2> + <itemValue3>3333</itemValue3> +</config> +``` + +#### Edit-config Action +Modifies existing configuration (e.g. change parameter values, modify or remove parameter from model). +To edit configuration, netconf compliant XML file should be prepared and used as one of edit-config parameters. +`usage: netconf_server.py edit-config [-h] --address ADDRESS --config <XML_CONFIG_FILEPATH> [--verbose]` + +example - parameter values modification +file content: +``` +<config xmlns="http://nokia.com/nf-simulator"> + <itemValue1>1</itemValue1> + <itemValue2>2</itemValue2> + <itemValue3>3</itemValue3> +</config> +``` + +example output (without verbose flag): +``` +Response status: 202 +<config xmlns="http://nokia.com/nf-simulator" xmlns:nc="urn:ietf:params:xml:ns:netconf:base:1.0"> + <itemValue1>1</itemValue1> + <itemValue2>2</itemValue2> + <itemValue3>3</itemValue3> +</config> +``` + +##### Less Action +Less action allows user to watch historical configuration changes. +Size of the configuration changes list is limited to the 100 last cm events by default, but can be incresed/decresead using a 'limit' attribute. +`usage: netconf_server.py less [-h] --address ADDRESS [--limit LIMIT] [--verbose]` + +Output from the command can be easily piped into other tools like native less, more, etc. e.g.: +`netconf_server.py less --address 127.0.0.1 | less` + +Last known configuration is last printed to the output, so order of the printed configuration events complies with time when the configuration was stored inside the simulator. + +Parameters: + +`--address ADDRESS` - `IP address of simulator` + +`--limit LIMIT` - ` Number of configurations to print at output` + +`--verbose` - ` Displays additional logs` + +Single message is represented as a pair of timestamp in epoch format and suitable configuration entry. + +##### Tailf Action +Tailf action allows user to actively listen for new uploaded configuration changes. +Size of the historical configuration changes list is limited to the 10 last cm events. +`usage: netconf_server.py tailf [-h] --address ADDRESS [--verbose]` + +The listener can be easily terminated at anytime using `CTRL+C` shortcut. + +Parameters: + +`--address ADDRESS` - `IP address of simulator` + +`--verbose` - ` Displays additional logs` + +Single message is represented as a pair of timestamp in epoch format and suitable configuration entry. + +##### Cm-history Action +Cm-history action allows user to view list of all uploaded configuration changes. +`usage: netconf_server.py cm-history [-h] --address ADDRESS [--verbose]` + +Last known configuration is last printed to the output, so order of the printed configuration events complies with time when the configuration was stored inside the simulator. + +Parameters: + +`--address ADDRESS` - `IP address of simulator` + +`--verbose` - ` Displays additional logs` + +Single message is represented as a pair of timestamp in epoch format and suitable configuration entry. diff --git a/simulator-cli/cli/__init__.py b/simulator-cli/cli/__init__.py new file mode 100644 index 0000000..bc242fe --- /dev/null +++ b/simulator-cli/cli/__init__.py @@ -0,0 +1,19 @@ +### +# ============LICENSE_START======================================================= +# Simulator +# ================================================================================ +# Copyright (C) 2021 Nokia. All rights reserved. +# ================================================================================ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============LICENSE_END========================================================= +### diff --git a/simulator-cli/cli/client/__init__.py b/simulator-cli/cli/client/__init__.py new file mode 100644 index 0000000..bc242fe --- /dev/null +++ b/simulator-cli/cli/client/__init__.py @@ -0,0 +1,19 @@ +### +# ============LICENSE_START======================================================= +# Simulator +# ================================================================================ +# Copyright (C) 2021 Nokia. All rights reserved. +# ================================================================================ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============LICENSE_END========================================================= +### diff --git a/simulator-cli/cli/client/tailf_client.py b/simulator-cli/cli/client/tailf_client.py new file mode 100644 index 0000000..1f46275 --- /dev/null +++ b/simulator-cli/cli/client/tailf_client.py @@ -0,0 +1,59 @@ +### +# ============LICENSE_START======================================================= +# Simulator +# ================================================================================ +# Copyright (C) 2021 Nokia. All rights reserved. +# ================================================================================ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============LICENSE_END========================================================= +### + +import logging + +import websockets +import asyncio +import signal +import sys + + +class TailfClient(object): + + def __init__(self, url: str, verbose: bool = False) -> None: + self._url = url + self._is_running = False + self._connection = None + self.logger = logging.getLogger() + self.logger.setLevel(logging.DEBUG if verbose else logging.INFO) + signal.signal(signal.SIGINT, self._handle_keyboard_interrupt) + + def tailf_messages(self): + self._is_running = True + self.logger.debug("Attempting to connect to websocket server on %s", self._url) + asyncio.get_event_loop().run_until_complete( + self._tailf_messages() + ) + + async def _tailf_messages(self): + try: + async with websockets.connect(self._url) as connection: + self.logger.debug("Connection with %s established", self._url) + self._connection = connection + while self._is_running: + print(await self._connection.recv(), "\n") + except ConnectionRefusedError: + self.logger.error("Cannot establish connection with %s", self._url) + + def _handle_keyboard_interrupt(self, sig, frame): + self.logger.warning("CTR-C pressed, interrupting.") + self._is_running = False + sys.exit(0) diff --git a/simulator-cli/cli/data/logging.ini b/simulator-cli/cli/data/logging.ini new file mode 100644 index 0000000..8b2b402 --- /dev/null +++ b/simulator-cli/cli/data/logging.ini @@ -0,0 +1,20 @@ +[loggers] +keys=root + +[handlers] +keys=consoleHandler + +[formatters] +keys=simpleFormatter + +[logger_root] +level=DEBUG +handlers=consoleHandler + +[handler_consoleHandler] +class=StreamHandler +formatter=simpleFormatter +args=(sys.stdout,) + +[formatter_simpleFormatter] +format=%(message)s diff --git a/simulator-cli/cli/netconf_server.py b/simulator-cli/cli/netconf_server.py new file mode 100644 index 0000000..57196fa --- /dev/null +++ b/simulator-cli/cli/netconf_server.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +### +# ============LICENSE_START======================================================= +# Simulator +# ================================================================================ +# Copyright (C) 2021 Nokia. All rights reserved. +# ================================================================================ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============LICENSE_END========================================================= +### + +import argparse +import logging +import logging.config +import requests +import os +import sys +from requests import Response + +from cli.client.tailf_client import TailfClient + +TAILF_FUNC_ENDPOINT = "ws://{}:9000/netconf" +LESS_FUNC_ENDPOINT = "/store/less" +CM_HISTORY_ENDPOINT = "/store/cm-history" +GET_CONFIG_ENDPOINT = "/netconf/get" +MODEL_ENDPOINT = "/netconf/model/{}" +EDIT_CONFIG_ENDPOINT = "/netconf/edit-config" +logging.basicConfig() + +DEFAULT_EXTERNAL_SIM_PORT = 8080 +DEFAULT_INTERNAL_SIM_PORT = 9000 + + +class NetconfSimulatorClient(object): + def __init__(self, ip: str, protocol: str = 'http', port: int = DEFAULT_EXTERNAL_SIM_PORT, verbose: bool = False) -> None: + self._ip = ip + self._protocol = protocol + self._port = port + self._configure_logger(verbose) + self._verbose=verbose + + def tailf_like_func(self) -> None: + url = TAILF_FUNC_ENDPOINT.format(self._ip) + client = TailfClient(url, self._verbose) + client.tailf_messages() + + def get_cm_history(self) -> None: + self.logger.info("Attempting to retrieve all netconf configuration changes") + simulator_address = "{}://{}:{}{}".format(self._protocol, self._ip, self._port, CM_HISTORY_ENDPOINT) + self.logger.debug("Simulator address: %s", simulator_address) + try: + response = requests.get(simulator_address) + self._log_json_response(response) + except requests.ConnectionError: + self.logger.error("Failed to establish connection with {}".format(simulator_address)) + + def less_like_func(self, limit: int) -> None: + self.logger.info("Attempting to run less on CM change") + simulator_address = "{}://{}:{}{}".format(self._protocol, self._ip, self._port, LESS_FUNC_ENDPOINT) + parameters = {"offset": limit} if limit else None + self.logger.debug("Simulator address: %s", simulator_address) + try: + response = requests.get(url = simulator_address, params = parameters) + self._log_json_response(response) + except requests.ConnectionError: + self.logger.error("Failed to establish connection with {}".format(simulator_address)) + + def get_config(self, module_name: str=None, container:str=None)-> None: + self.logger.info("Attempting to run get-config") + simulator_address = self._create_get_endpoint(module_name, container) + self.logger.debug("Simulator address: %s", simulator_address) + try: + response = requests.get(simulator_address) + self._log_string_response(response) + except requests.ConnectionError: + self.logger.error("Failed to establish connection with {}".format(simulator_address)) + + def load_yang_model(self, module_name: str, yang_model_path: str, config_path: str) -> None: + self.logger.info( + "Attempting to load new yang model with its initial configuration") + simulator_address = "{}://{}:{}{}".format(self._protocol, self._ip, self._port, MODEL_ENDPOINT.format(module_name)) + files = {"yangModel": open(yang_model_path, "rb"), + "initialConfig": open(config_path, "rb")} + self.logger.debug("Simulator address: %s", simulator_address) + + try: + response = requests.post(simulator_address, files=files) + self._log_string_response(response) + except requests.ConnectionError: + self.logger.error("Failed to establish connection with {}".format(simulator_address)) + + def delete_yang_model(self, model_name: str) -> None: + self.logger.info( + "Attempting to delete a yang model") + simulator_address = "{}://{}:{}{}".format(self._protocol, self._ip, self._port, MODEL_ENDPOINT.format(model_name)) + self.logger.debug("Simulator address: %s", simulator_address) + + try: + response = requests.delete(simulator_address) + self._log_string_response(response) + except requests.ConnectionError: + self.logger.error("Failed to establish connection with {}".format(simulator_address)) + + def edit_config(self, new_config_path: str): + self.logger.info("Attempting to apply new configuration") + simulator_address = "{}://{}:{}{}".format(self._protocol, self._ip, self._port, EDIT_CONFIG_ENDPOINT) + files = {"editConfigXml": open(new_config_path,"rb")} + self.logger.debug("Simulator address: %s", simulator_address) + + try: + response = requests.post(simulator_address, files=files) + self._log_string_response(response) + except requests.ConnectionError: + self.logger.error("Failed to establish connection with {}".format(simulator_address)) + + def _log_json_response(self, response: Response) ->None: + self.logger.info("Response status: %d", response.status_code) + self.logger.info(" ----- HEAD -----") + for message in response.json(): + self.logger.info("{}: {}".format(str(message['timestamp']), message['configuration'])) + self.logger.info(" ----- END ------") + self.logger.debug(response.headers) + + def _configure_logger(self, verbose): + logging_conf = os.path.join(sys.prefix, 'logging.ini') + if os.path.exists(logging_conf): + logging.config.fileConfig(logging_conf) + else: + print("Couldn't find logging.ini, using default logger config") + self.logger = logging.getLogger() + self.logger.setLevel(logging.DEBUG if verbose else logging.INFO) + + def _log_string_response(self, response: Response)->None: + self.logger.info("Response status: %d", response.status_code) + self.logger.info(response.text) + self.logger.debug(response.headers) + + def _create_get_endpoint(self, module_name: str, container: str): + endpoint = "{}://{}:{}{}".format(self._protocol, self._ip, self._port, + GET_CONFIG_ENDPOINT) + if module_name and container: + endpoint = endpoint + "/{}/{}".format(module_name, container) + elif (not module_name and container) or (module_name and not container): + raise AttributeError( + "Both module_name and container must be present or absent") + return endpoint + +def create_argument_parser(): + parser = argparse.ArgumentParser(description="Netconf Simulator Command Line Interface. ") + subparsers = parser.add_subparsers(title="Available actions") + tailf_parser = subparsers.add_parser("tailf", + description="Method which allows user to view N last lines of configuration changes") + + __configure_tailf_like_parser(tailf_parser) + less_parser = subparsers.add_parser("less", description="Method which allows user to traverse configuration changes") + __configure_less_like_parser(less_parser) + cm_history_parser = subparsers.add_parser("cm-history", + description="Method which allows user to view all configuration changes") + __configure_cm_history_parser(cm_history_parser) + + load_model_parser = subparsers.add_parser("load-model") + __configure_load_model_parser(load_model_parser) + + delete_model_parser = subparsers.add_parser("delete-model") + __configure_delete_model_parser(delete_model_parser) + + get_config_parser = subparsers.add_parser("get-config") + __configure_get_config_parser(get_config_parser) + edit_config_parser = subparsers.add_parser("edit-config") + __configure_edit_config_parser(edit_config_parser) + return parser + + +def run_tailf(args): + client = NetconfSimulatorClient(args.address, verbose=args.verbose) + client.tailf_like_func() + + +def run_get_cm_history(args): + client = NetconfSimulatorClient(args.address, verbose=args.verbose, port=DEFAULT_INTERNAL_SIM_PORT) + client.get_cm_history() + + +def run_less(args): + client = NetconfSimulatorClient(args.address, verbose=args.verbose, port=DEFAULT_INTERNAL_SIM_PORT) + client.less_like_func(args.limit) + + +def run_load_model(args): + client = NetconfSimulatorClient(args.address, verbose=args.verbose, + port=DEFAULT_INTERNAL_SIM_PORT) + client.load_yang_model(args.module_name, args.yang_model, args.config) + + +def run_delete_model(args): + client = NetconfSimulatorClient(args.address, verbose=args.verbose, + port=DEFAULT_INTERNAL_SIM_PORT) + client.delete_yang_model(args.model_name) + + +def run_get_config(args): + client = NetconfSimulatorClient(args.address, verbose=args.verbose, port=DEFAULT_INTERNAL_SIM_PORT) + client.get_config(args.module_name, args.container) + + +def run_edit_config(args): + client = NetconfSimulatorClient(args.address, verbose=args.verbose, port=DEFAULT_INTERNAL_SIM_PORT) + client.edit_config(args.config) + + +def __configure_tailf_like_parser(tailf_func_parser): + tailf_func_parser.add_argument("--address", required=True, help="IP address of simulator") + tailf_func_parser.add_argument("--verbose", action='store_true', + help="Displays additional logs") + tailf_func_parser.set_defaults(func=run_tailf) + + +def __configure_less_like_parser(less_func_parser): + less_func_parser.add_argument("--address", required=True, help="IP address of simulator") + less_func_parser.add_argument("--limit", help="Limit of configurations to retrieve") + less_func_parser.add_argument("--verbose", action='store_true', help="Displays additional logs") + less_func_parser.set_defaults(func=run_less) + + +def __configure_cm_history_parser(cm_history_parser): + cm_history_parser.add_argument("--address", required=True, help="IP address of simulator") + cm_history_parser.add_argument("--verbose", action='store_true', help="Displays additional logs") + cm_history_parser.set_defaults(func=run_get_cm_history) + + +def __configure_load_model_parser(load_model_parser): + load_model_parser.add_argument("--address", required=True, help="IP address of simulator") + load_model_parser.add_argument("--module-name", required=True, help="Module name corresponding to yang-model") + load_model_parser.add_argument("--verbose", action='store_true', help="Displays additional logs") + load_model_parser.add_argument("--yang-model", required=True, help="Path to file with yang model") + load_model_parser.add_argument("--config", required=True, help="Path to file with initial xml config") + load_model_parser.set_defaults(func=run_load_model) + + +def __configure_delete_model_parser(delete_model_parser): + delete_model_parser.add_argument("--address", required=True, help="IP address of simulator") + delete_model_parser.add_argument("--model-name", required=True, help="YANG model name to delete") + delete_model_parser.add_argument("--verbose", action='store_true', help="Displays additional logs") + delete_model_parser.set_defaults(func=run_delete_model) + + +def __configure_get_config_parser(get_config_parser): + get_config_parser.add_argument("--address", required=True, help="IP address of simulator") + get_config_parser.add_argument("--verbose", action='store_true',help="Displays additional logs") + get_config_parser.add_argument("--module-name", help="Module name corresponding to yang-model", default=None) + get_config_parser.add_argument("--container", help="Container name corresponding to module name", default=None) + get_config_parser.set_defaults(func=run_get_config) + + +def __configure_edit_config_parser(edit_config_parser): + edit_config_parser.add_argument("--address", required=True, help="IP address of simulator") + edit_config_parser.add_argument("--verbose", action='store_true', help="Displays additional logs") + edit_config_parser.add_argument("--config", required=True, help="Path to file with xml config to apply") + edit_config_parser.set_defaults(func=run_edit_config) + + +if __name__ == "__main__": + argument_parser = create_argument_parser() + result = argument_parser.parse_args() + if hasattr(result, 'func'): + result.func(result) + else: + argument_parser.parse_args(['-h']) diff --git a/simulator-cli/cli/nf_simulator.py b/simulator-cli/cli/nf_simulator.py new file mode 100644 index 0000000..1964b6d --- /dev/null +++ b/simulator-cli/cli/nf_simulator.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +### +# ============LICENSE_START======================================================= +# Simulator +# ================================================================================ +# Copyright (C) 2021 Nokia. All rights reserved. +# ================================================================================ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============LICENSE_END========================================================= +### +import argparse +import http.client +import json +import logging +import ntpath +from typing import Dict + +SEND_PERIODIC_EVENT_ENDPOINT = "/simulator/start" +SEND_ONE_TIME_EVENT_ENDPOINT = "/simulator/event" +CONFIG_ENDPOINT = "/simulator/config" +LIST_TEMPLATES_ENDPOINT = "/template/list" +GET_TEMPLATE_BY_NAME_ENDPOINT = "/template/get" +UPLOAD_TEMPLATE_NOFORCE = "/template/upload" +UPLOAD_TEMPLATE_FORCE = "/template/upload?override=true" +FILTER_TEMPLATES_ENDPOINT = "/template/search" + +logging.basicConfig() + + +class Messages(object): + OVERRIDE_VALID_ONLY_WITH_UPLOAD = "--override is valid only with --upload parameter" + + +class SimulatorParams(object): + def __init__(self, repeats: int = 1, interval: int = 1, ves_server_url: str = None) -> None: + self.repeats_count = repeats + self.repeats_interval = interval + self.ves_server_url = ves_server_url + + def to_json(self) -> Dict: + to_return = {"repeatCount": self.repeats_count, + "repeatInterval": self.repeats_interval} + if self.ves_server_url: + to_return["vesServerUrl"] = self.ves_server_url + return to_return + + def __repr__(self) -> str: + return str(self.to_json()) + + +class PersistedEventRequest(object): + def __init__(self, simulator_params: SimulatorParams, template: str, patch: Dict = None) -> None: + self.params = simulator_params + self.template = template + self.patch = patch or {} + + def to_json(self) -> Dict: + return {"simulatorParams": self.params, "templateName": self.template, + "patch": self.patch} + + def __repr__(self) -> str: + return str(self.to_json()) + + +class FullEventRequest(object): + def __init__(self, event_body: Dict, ves_server_url: str = None) -> None: + self.event_body = event_body + self.ves_server_url = ves_server_url or "" + + def to_json(self) -> Dict: + return {"vesServerUrl": self.ves_server_url, "event": self.event_body} + + def __repr__(self) -> str: + return str(self.to_json()) + + +class TemplateUploadRequest(object): + def __init__(self, template_name: str, template_body: Dict) -> None: + self.template_name = template_name + self.template_body = template_body + + def to_json(self) -> Dict: + return {"name": self.template_name, "template": self.template_body} + + def __repr__(self) -> str: + return str(self.to_json()) + + +class SimulatorClient(object): + def __init__(self, ip: str, port: int = 5000, verbose: bool = False) -> None: + self._ip = ip + self._port = port + self.logger = logging.getLogger() + self.logger.setLevel(logging.DEBUG if verbose else logging.INFO) + + def send_event(self, request: PersistedEventRequest) -> None: + connection = http.client.HTTPConnection(self._ip, self._port) + self.logger.info("Attempting to send event") + self.logger.debug("Simulator address: ip %s, port %s, endpoint %s", self._ip, self._port, SEND_PERIODIC_EVENT_ENDPOINT) + self.logger.debug("REQUEST %s", request) + + connection.request("POST", SEND_PERIODIC_EVENT_ENDPOINT, body=json.dumps(request, cls=RequestSerializer), + headers={"Content-Type": "application/json"}) + + response = connection.getresponse() + + self._log_response(response) + connection.close() + + def send_one_time_event(self, request: FullEventRequest) -> None: + connection = http.client.HTTPConnection(self._ip, self._port) + self.logger.info("Attempting to send one time event") + self.logger.debug("Simulator address: ip %s, port %s, endpoint %s", self._ip, self._port, SEND_ONE_TIME_EVENT_ENDPOINT) + self.logger.debug("REQUEST %s", request.to_json()) + + connection.request("POST", SEND_ONE_TIME_EVENT_ENDPOINT, body=json.dumps(request.to_json()), + headers={"Content-Type": "application/json"}) + + response = connection.getresponse() + + self._log_response(response) + connection.close() + + def get_configuration(self) -> None: + connection = http.client.HTTPConnection(self._ip, self._port) + self.logger.info("Attempting to retrieve Simulator configuration") + self.logger.debug("Simulator address: ip %s, port %s, endpoint %s", self._ip, self._port, CONFIG_ENDPOINT) + connection.request("GET", CONFIG_ENDPOINT) + response = connection.getresponse() + + self._log_response(response) + connection.close() + + def edit_configuration(self, ves_server_url: str) -> None: + connection = http.client.HTTPConnection(self._ip, self._port) + self.logger.info("Attempting to update Simulator configuration") + self.logger.debug("Simulator address: ip %s, port %s, endpoint %s", self._ip, self._port, CONFIG_ENDPOINT) + request = {"vesServerUrl": ves_server_url} + self.logger.debug("REQUEST %s", request) + connection.request("PUT", CONFIG_ENDPOINT, body=json.dumps(request), + headers={"Content-Type": "application/json"}) + + response = connection.getresponse() + + self._log_response(response) + connection.close() + + def _log_response(self, response: http.client.HTTPResponse): + self.logger.info("Response status: %s ", response.status) + self.logger.info(response.read().decode()) + self.logger.debug(response.headers) + + def list_templates(self): + connection = http.client.HTTPConnection(self._ip, self._port) + self.logger.info("Attempting to retrieve all templates") + self.logger.debug("Simulator address: ip %s, port %s, endpoint %s", self._ip, self._port, LIST_TEMPLATES_ENDPOINT) + connection.request("GET", LIST_TEMPLATES_ENDPOINT) + response = connection.getresponse() + + self._log_response(response) + connection.close() + + def get_template_by_name(self, name): + connection = http.client.HTTPConnection(self._ip, self._port) + endpoint = GET_TEMPLATE_BY_NAME_ENDPOINT + "/" + name + self.logger.info("Attempting to retrieve template by name: '%s'", name) + self.logger.debug("Simulator address: ip %s, port %s, endpoint %s", self._ip, self._port, endpoint) + connection.request("GET", endpoint) + response = connection.getresponse() + + self._log_response(response) + connection.close() + + def upload_template(self, template_request, force): + connection = http.client.HTTPConnection(self._ip, self._port) + endpoint = UPLOAD_TEMPLATE_FORCE if force else UPLOAD_TEMPLATE_NOFORCE + self.logger.info("Attempting to upload template: '%s'", template_request) + self.logger.debug("Simulator address: ip %s, port %s, endpoint %s", self._ip, self._port, endpoint) + connection.request("POST", endpoint, + body=json.dumps(template_request.to_json()), + headers={"Content-Type": "application/json"}) + response = connection.getresponse() + + self._log_response(response) + connection.close() + + def search_for_templates(self, filter_criteria: str): + connection = http.client.HTTPConnection(self._ip, self._port) + self.logger.debug("Simulator address: ip %s, port %s, endpoint %s", self._ip, self._port, FILTER_TEMPLATES_ENDPOINT) + filter_request = {"searchExpr": json.loads(filter_criteria)} + self.logger.debug("Filter criteria: %s", str(filter_criteria)) + connection.request("POST", FILTER_TEMPLATES_ENDPOINT, + body=json.dumps(filter_request), + headers={"Content-Type": "application/json"}) + response = connection.getresponse() + + self._log_response(response) + connection.close() + + +class RequestSerializer(json.JSONEncoder): + def default(self, o): + return o.to_json() if (isinstance(o, SimulatorParams) or isinstance(o, PersistedEventRequest)) else o + + + +def create_argument_parser(): + parser = argparse.ArgumentParser(description="NF Simulator Command Line Interface. ") + subparsers = parser.add_subparsers(title="Available actions") + send_parser = subparsers.add_parser("send", + description="Method which allows user to trigger simulator to start sending " + "events. Available options: [template, event]") + + send_subparsers = send_parser.add_subparsers() + one_time_send_event_parser = send_subparsers.add_parser("event", description="Option for direct, one-time event sending to VES. This option does not require having corresponging template.") + __configure_one_time_send_parser(one_time_send_event_parser) + persisted_send_event_parser = send_subparsers.add_parser("template") + __configure_persisted_send_parser(persisted_send_event_parser) + + configure_parser = subparsers.add_parser("configure", description="Method which allows user to set new default " + "value for VES Endpoint") + __configure_config_parser(configure_parser) + + get_config_parser = subparsers.add_parser("get-config", + description="Method which allows user to view simulator configuration") + __configure_get_config_parser(get_config_parser) + + template_config_parser = subparsers.add_parser("template", description="Template management operations") + __configure_template_parser(template_config_parser) + + template_filter_parser = subparsers.add_parser("filter", description="Method for searching through templates to find those satisfying given criteria") + __configure_template_filter_parser(template_filter_parser) + + return parser + + +def _perform_send_action(args): + if (not args.interval and args.repeats) or (args.interval and not args.repeats): + raise Exception("Either both repeats and interval must be present or missing") + + client = SimulatorClient(args.address, verbose=args.verbose) + client.send_event(_create_scheduled_event_request(args)) + + +def _perform_one_time_send_action(args): + client = SimulatorClient(args.address, verbose=args.verbose) + client.send_one_time_event(_create_one_time_event_request(args.filepath, args.ves_server_url)) + + +def get_configuration(args): + client = SimulatorClient(args.address, verbose=args.verbose) + client.get_configuration() + + +def edit_configuration(args): + client = SimulatorClient(args.address, verbose=args.verbose) + client.edit_configuration(args.ves_server_url) + + +def perform_template_action(args): + client = SimulatorClient(args.address, verbose=args.verbose) + if args.list: + client.list_templates() + elif args.get_content: + client.get_template_by_name(args.get_content) + elif args.upload: + client.upload_template(_create_upload_template_request(args.upload), args.override) + elif args.force: + raise Exception(Messages.OVERRIDE_VALID_ONLY_WITH_UPLOAD) + + +def list_all_templates(args): + client = SimulatorClient(args.address, verbose=args.verbose) + client.list_templates() + + +def filter_templates(args): + client = SimulatorClient(args.address, verbose=args.verbose) + client.search_for_templates(args.criteria) + + +def _create_upload_template_request(template_filename): + with open(template_filename) as json_template: + template_body = json.load(json_template) + return TemplateUploadRequest(path_leaf(template_filename), template_body) + + +def _create_scheduled_event_request(args): + simulator_params = SimulatorParams(args.repeats, args.interval, args.ves_server_url) + return PersistedEventRequest(simulator_params, args.name, json.loads(args.patch) if args.patch else {}) + + +def _create_one_time_event_request(event_filename, ves_server_url): + with open(event_filename) as json_event: + event_body = json.load(json_event) + return FullEventRequest(event_body, ves_server_url) + + +def __configure_persisted_send_parser(send_parser): + send_parser.add_argument("--address", required=True, help="IP address of simulator") + send_parser.add_argument("--name", required=True, help="Name of template file which should be used as a base for event") + send_parser.add_argument("--patch", help="Json which should be merged into template to override parameters") + send_parser.add_argument("--repeats", help="Number of events to be send", type=int) + send_parser.add_argument("--interval", help="Interval between two consecutive events (in seconds)", type=int) + send_parser.add_argument("--ves_server_url", + help="Well-formed URL which will override current VES endpoint stored in simulator's DB") + send_parser.add_argument("--verbose", action='store_true', help="Displays additional logs") + send_parser.set_defaults(func=_perform_send_action) + + +def __configure_one_time_send_parser(send_parser): + send_parser.add_argument("--address", required=True, help="IP address of simulator") + send_parser.add_argument("--filepath", required=True, help="Name of file with complete event for direct sending.") + send_parser.add_argument("--ves_server_url", + help="Well-formed URL which will override current VES endpoint stored in simulator's DB") + send_parser.add_argument("--verbose", action='store_true', help="Displays additional logs") + send_parser.set_defaults(func=_perform_one_time_send_action) + + +def __configure_config_parser(config_parser): + config_parser.add_argument("--address", required=True, help="IP address of simulator") + config_parser.add_argument("--ves-server-url", required=True, + help="Well-formed URL which should be set as a default VES Server URL in simulator") + config_parser.add_argument("--verbose", action='store_true', help="Displays additional logs") + config_parser.set_defaults(func=edit_configuration) + + +def __configure_get_config_parser(get_config_parser): + get_config_parser.add_argument("--address", required=True, help="IP address of simulator") + get_config_parser.add_argument("--verbose", action='store_true', help="Displays additional logs") + get_config_parser.set_defaults(func=get_configuration) + + +def __configure_template_parser(template_config_parser): + group = template_config_parser.add_mutually_exclusive_group(required=True) + group.add_argument("--list", action='store_true', help="List all templates") + group.add_argument("--get-content", help="Gets the template by name") + group.add_argument("--upload", help="Uploads the template given in parameter file.") + + template_config_parser.add_argument("--override", action='store_true', help="Overwrites the template in case it exists.") + template_config_parser.add_argument("--address", required=True, help="IP address of simulator") + template_config_parser.add_argument("--verbose", action='store_true', help="Displays additional logs") + template_config_parser.set_defaults(func=perform_template_action) + + +def __configure_template_filter_parser(template_filter_parser): + template_filter_parser.add_argument("--criteria", required=True, help="Json string with key-value search criteria") + template_filter_parser.add_argument("--address", required=True, help="IP address of simulator") + template_filter_parser.add_argument("--verbose", action='store_true', help="Displays additional logs") + template_filter_parser.set_defaults(func=filter_templates) + + +def path_leaf(path): + head, tail = ntpath.split(path) + return tail or ntpath.basename(head) + + +if __name__ == "__main__": + argument_parser = create_argument_parser() + result = argument_parser.parse_args() + if hasattr(result, 'func'): + result.func(result) + else: + argument_parser.parse_args(['-h']) diff --git a/simulator-cli/requirements.txt b/simulator-cli/requirements.txt new file mode 100644 index 0000000..afa6725 --- /dev/null +++ b/simulator-cli/requirements.txt @@ -0,0 +1,24 @@ +### +# ============LICENSE_START======================================================= +# Simulator +# ================================================================================ +# Copyright (C) 2019 Nokia. All rights reserved. +# ================================================================================ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============LICENSE_END========================================================= +### +requests==2.20.1 +websockets==7.0 +asynctest +mock +pytest diff --git a/simulator-cli/setup.py b/simulator-cli/setup.py new file mode 100644 index 0000000..e311abc --- /dev/null +++ b/simulator-cli/setup.py @@ -0,0 +1,34 @@ +### +# ============LICENSE_START======================================================= +# Simulator +# ================================================================================ +# Copyright (C) 2021 Nokia. All rights reserved. +# ================================================================================ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============LICENSE_END========================================================= +### + +import setuptools + +setuptools.setup( + name="nf_simulator_cli", + version="5.0.0", + description="Command line interface which allows to communicate with NF SIMULATOR", + packages=setuptools.find_packages(), + data_files=['cli/data/logging.ini'], + classifiers=["Programming Language :: Python :: 3"], + install_requires=[ + 'requests==2.20.1', + 'websockets==7.0' + ] +) diff --git a/simulator-cli/tests/__init__.py b/simulator-cli/tests/__init__.py new file mode 100644 index 0000000..bc242fe --- /dev/null +++ b/simulator-cli/tests/__init__.py @@ -0,0 +1,19 @@ +### +# ============LICENSE_START======================================================= +# Simulator +# ================================================================================ +# Copyright (C) 2021 Nokia. All rights reserved. +# ================================================================================ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============LICENSE_END========================================================= +### diff --git a/simulator-cli/tests/resources/notification.json b/simulator-cli/tests/resources/notification.json new file mode 100644 index 0000000..ca9641e --- /dev/null +++ b/simulator-cli/tests/resources/notification.json @@ -0,0 +1,17 @@ +{ + "commonEventHeader": { + "domain": "notification", + "eventName": "#RandomString(20)", + "version": "4.0.1" + }, + "notificationFields": { + "arrayOfNamedHashMap": [ + { + "name": "A20161221.1031-1041.bin.gz", + "hashMap": { + "fileformatType": "org.3GPP.32.435#measCollec" + } + } + ] + } +} diff --git a/simulator-cli/tests/test_netconf_server.py b/simulator-cli/tests/test_netconf_server.py new file mode 100644 index 0000000..8e39184 --- /dev/null +++ b/simulator-cli/tests/test_netconf_server.py @@ -0,0 +1,165 @@ +### +# ============LICENSE_START======================================================= +# Simulator +# ================================================================================ +# Copyright (C) 2021 Nokia. All rights reserved. +# ================================================================================ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============LICENSE_END========================================================= +### +import logging +import unittest +import os +from mock import patch + +from cli.netconf_server import create_argument_parser, NetconfSimulatorClient + + +class TestArgumentParser(unittest.TestCase): + + def test_should_properly_parse_edit_config_with_all_params(self): + parser = create_argument_parser() + args = parser.parse_args( + ['edit-config', '--address', '127.0.0.1', '--config', 'sample_path', + "--verbose"] + ) + + self.assertEqual(args.address, '127.0.0.1') + self.assertEqual(args.config, 'sample_path') + self.assertTrue(args.verbose) + + def test_should_properly_parse_load_yang_model(self): + parser = create_argument_parser() + + args = parser.parse_args( + ['load-model', '--address', '127.0.0.1', '--module-name', + 'sample_name', '--yang-model', 'sample_model', '--config', + 'sample_config', + "--verbose"] + ) + + self.assertEqual(args.address, '127.0.0.1') + self.assertEqual(args.config, 'sample_config') + self.assertEqual(args.yang_model, 'sample_model') + self.assertEqual(args.module_name, 'sample_name') + self.assertTrue(args.verbose) + + def test_should_properly_parse_delete_yang_model(self): + parser = create_argument_parser() + + args = parser.parse_args( + ['delete-model', '--address', '127.0.0.1', '--model-name', + 'sample_name', "--verbose"] + ) + + self.assertEqual(args.address, '127.0.0.1') + self.assertEqual(args.model_name, 'sample_name') + self.assertTrue(args.verbose) + + def test_should_properly_parse_get_config(self): + parser = create_argument_parser() + args = parser.parse_args( + ['get-config', '--address', '127.0.0.1', '--verbose'] + ) + + self.assertEqual(args.address, '127.0.0.1') + self.assertTrue(args.verbose) + + +class TestNetconfSimulatorClient(unittest.TestCase): + + @classmethod + def setUpClass(cls): + with open("example", "w+") as f: + f.write("sampleContent") + + @classmethod + def tearDownClass(cls): + os.remove("example") + + @patch('cli.netconf_simulator.requests') + @patch('cli.netconf_simulator.NetconfSimulatorClient._configure_logger') + def test_should_properly_get_config(self, logger, requests): + client = NetconfSimulatorClient('localhost') + client.logger = logging.getLogger() + + client.get_config() + + requests.get.assert_called_with('http://localhost:8080/netconf/get') + + @patch('cli.netconf_simulator.requests') + @patch('cli.netconf_simulator.NetconfSimulatorClient._configure_logger') + def test_should_properly_get_config_for_given_module(self, logger, requests): + client = NetconfSimulatorClient('localhost') + client.logger = logging.getLogger() + + client.get_config("module", "container") + + requests.get.assert_called_with('http://localhost:8080/netconf/get/module/container') + + @patch('cli.netconf_simulator.NetconfSimulatorClient._configure_logger') + def test_should_raise_exception_when_module_is_present_and_container_is_absent(self, logger): + client = NetconfSimulatorClient('localhost') + client.logger = logging.getLogger() + + with self.assertRaises(AttributeError) as context: # pylint: disable=W0612 + client.get_config(module_name="test") + + @patch('cli.netconf_simulator.NetconfSimulatorClient._configure_logger') + def test_should_raise_exception_when_module_is_absent_and_container_is_present(self, logger): + client = NetconfSimulatorClient('localhost') + client.logger = logging.getLogger() + + with self.assertRaises(AttributeError) as context: # pylint: disable=W0612 + client.get_config(container="test") + + @patch('cli.netconf_simulator.requests') + @patch('cli.netconf_simulator.NetconfSimulatorClient._configure_logger') + def test_should_properly_load_yang_model(self, logger, requests): + client = NetconfSimulatorClient('localhost') + client.logger = logging.getLogger() + + client.load_yang_model('sample_module_name', 'example', 'example') + + requests.post.assert_called() + + @patch('cli.netconf_simulator.requests') + @patch('cli.netconf_simulator.NetconfSimulatorClient._configure_logger') + def test_should_properly_delete_yang_model(self, logger, requests): + client = NetconfSimulatorClient('localhost') + client.logger = logging.getLogger() + + client.delete_yang_model('sample_model_name') + + requests.delete.assert_called() + + @patch('cli.netconf_simulator.requests') + @patch('cli.netconf_simulator.NetconfSimulatorClient._configure_logger') + def test_should_properly_edit_config(self, logger, requests): + client = NetconfSimulatorClient('localhost') + client.logger = logging.getLogger() + + client.edit_config('example') + + requests.post.assert_called() + + @patch('cli.netconf_simulator.requests') + @patch('cli.netconf_simulator.NetconfSimulatorClient._configure_logger') + def test_should_properly_run_less_like_mode(self, logger, requests): + client = NetconfSimulatorClient('localhost') + client.logger = logging.getLogger() + + client.less_like_func(100) + + requests.get.assert_called_with( + params={"offset": 100}, url="http://localhost:8080/store/less") diff --git a/simulator-cli/tests/test_nf_simulator.py b/simulator-cli/tests/test_nf_simulator.py new file mode 100644 index 0000000..4f3fdb8 --- /dev/null +++ b/simulator-cli/tests/test_nf_simulator.py @@ -0,0 +1,270 @@ +### +# ============LICENSE_START======================================================= +# Simulator +# ================================================================================ +# Copyright (C) 2021 Nokia. All rights reserved. +# ================================================================================ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============LICENSE_END========================================================= +### +import json +import os +import unittest +from http.client import HTTPResponse, HTTPConnection +from unittest import mock +from unittest.mock import patch, Mock + +from cli.nf_simulator import SimulatorClient, FullEventRequest, Messages +from cli.nf_simulator import create_argument_parser, SimulatorParams, PersistedEventRequest + + +class TestArgumentParser(unittest.TestCase): + + def test_should_properly_parse_send_template_action_with_all_params(self): + parser = create_argument_parser() + + result = parser.parse_args( + ['send', 'template', '--address', '127.0.0.1', "--name", 'sample_template', '--patch', '"{}"', '--repeats', '2', + "--interval", '5', '--verbose', '--ves_server_url', 'sample_url']) + + self.assertEqual(result.address, '127.0.0.1') + self.assertEqual(result.name, "sample_template") + self.assertEqual(result.patch, "\"{}\"") + self.assertEqual(result.repeats, 2) + self.assertEqual(result.interval, 5) + self.assertEqual(result.ves_server_url, 'sample_url') + self.assertTrue(result.verbose) + + def test_should_properly_parse_send_event_action_with_all_params(self): + parser = create_argument_parser() + + result = parser.parse_args( + ['send', 'event', '--address', '127.0.0.1', "--filepath", 'sample_filepath.json', '--verbose', '--ves_server_url', 'sample_url']) + + self.assertEqual(result.address, '127.0.0.1') + self.assertEqual(result.filepath, "sample_filepath.json") + self.assertEqual(result.ves_server_url, 'sample_url') + self.assertTrue(result.verbose) + + def test_should_properly_parse_configure_action_with_all_params(self): + parser = create_argument_parser() + result = parser.parse_args( + ['configure', '--address', '127.0.0.1', "--verbose", '--ves-server-url', 'sample_url'] + ) + + self.assertEqual(result.address, '127.0.0.1') + self.assertTrue(result.verbose) + self.assertEqual(result.ves_server_url, 'sample_url') + + def test_should_properly_parse_get_config_action_with_all_params(self): + parser = create_argument_parser() + result = parser.parse_args( + ['get-config', '--address', '127.0.0.1', '--verbose'] + ) + + self.assertEqual(result.address, '127.0.0.1') + self.assertTrue(result.verbose) + + def test_should_not_parse_arguments_when_mandatory_params_are_missing_for_template(self): + parser = create_argument_parser() + + with self.assertRaises(SystemExit) as context: + parser.parse_args(['send', 'template']) + self.assertTrue('the following arguments are required: --address, --name' in context.exception) + + def test_should_not_parse_arguments_when_mandatory_params_are_missing_for_event(self): + parser = create_argument_parser() + + with self.assertRaises(SystemExit) as context: + parser.parse_args(['send', 'event']) + self.assertTrue('the following arguments are required: --address, --filepath' in context.exception) + + def test_should_not_parse_arguments_when_mandatory_template_params_are_missing(self): + parser = create_argument_parser() + + with self.assertRaises(SystemExit) as context: + parser.parse_args(['template']) + self.assertTrue('one of the arguments --list --get-content is required' in context.exception) + + def test_should_not_parse_template_action_with_all_params(self): + parser = create_argument_parser() + with self.assertRaises(SystemExit) as context: + parser.parse_args( + ['template', '--address', '127.0.0.1', "--list", '--get-content', 'sample'] + ) + self.assertTrue('argument --get-content: not allowed with argument --list' in context.exception) + + def test_should_properly_parse_template_action_with_list_param(self): + parser = create_argument_parser() + result = parser.parse_args( + ['template', '--address', '127.0.0.1', "--list"] + ) + + self.assertTrue(result.list) + self.assertEqual(result.address, '127.0.0.1') + self.assertFalse(result.verbose) + + def test_should_properly_parse_template_action_with_get_content_param(self): + parser = create_argument_parser() + result = parser.parse_args( + ['template', '--address', '127.0.0.1', "--get-content", "sample"] + ) + + self.assertTrue(result.get_content) + self.assertEqual(result.address, '127.0.0.1') + self.assertFalse(result.verbose) + + def test_should_not_parse_template_action_with_empty_get_content_param(self): + parser = create_argument_parser() + with self.assertRaises(SystemExit) as context: + parser.parse_args( + ['template', '--address', '127.0.0.1', "--list", '--get-content'] + ) + self.assertTrue('argument --get-content: expected one argument' in context.exception) + + def test_should_not_parse_template_action_when_only_override_is_given(self): + parser = create_argument_parser() + with self.assertRaises(SystemExit) as context: + parser.parse_args( + ['template', '--address', '127.0.0.1', "--override"] + ) + self.assertTrue(Messages.OVERRIDE_VALID_ONLY_WITH_UPLOAD in context.exception) + + def test_should_parse_template_action_with_upload(self): + parser = create_argument_parser() + result = parser.parse_args( + ['template', '--address', '127.0.0.1', "--upload", "resources/notification.json"] + ) + + self.assertFalse(result.override) + self.assertEqual(result.upload, 'resources/notification.json') + + def test_should_parse_template_action_with_upload_and_override(self): + parser = create_argument_parser() + result = parser.parse_args( + ['template', '--address', '127.0.0.1', "--upload", "resources/notification.json", "--override"] + ) + + self.assertTrue(result.override) + self.assertEqual(result.upload, 'resources/notification.json') + + + def test_should_properly_parse_filter_templates_action_with_all_params(self): + parser = create_argument_parser() + + result = parser.parse_args( + ['filter', '--address', '127.0.0.1', '--criteria', '"{}"', '--verbose']) + + self.assertEqual(result.address, '127.0.0.1') + self.assertEqual(result.criteria, "\"{}\"") + self.assertTrue(result.verbose) + +class TestSimulatorClient(unittest.TestCase): + + @patch('cli.nf_simulator.http.client.HTTPConnection') + def test_should_properly_send_event(self, http_connection): + request = self._create_request() + mocked_connection = Mock(HTTPConnection) + http_connection.return_value = mocked_connection + mocked_response = Mock(HTTPResponse) + mocked_connection.getresponse.return_value = mocked_response + mocked_response.status = '200' + mocked_response.headers = {} + + client = SimulatorClient('localhost') + client.send_event(request) + + mocked_connection.close.assert_called_with() + mocked_connection.request.assert_called_with('POST', '/simulator/start', + body=mock.ANY, + headers={'Content-Type': 'application/json'}) + + @patch('cli.nf_simulator.http.client.HTTPConnection') + def test_should_properly_send_one_time_event(self, http_connection): + event_abs_filepath = os.path.join(os.path.dirname(os.path.abspath(__file__)),"resources/notification.json") + request = self._create_one_time_request(event_abs_filepath) + mocked_connection = Mock(HTTPConnection) + http_connection.return_value = mocked_connection + mocked_response = Mock(HTTPResponse) + mocked_connection.getresponse.return_value = mocked_response + mocked_response.status = '202' + mocked_response.headers = {} + + client = SimulatorClient('localhost') + client.send_one_time_event(request) + + mocked_connection.close.assert_called_with() + mocked_connection.request.assert_called_with('POST', '/simulator/event', + body=mock.ANY, + headers={'Content-Type': 'application/json'}) + + @patch('cli.nf_simulator.http.client.HTTPConnection') + def test_should_properly_update_configuration(self, http_connection): + mocked_connection = Mock(HTTPConnection) + http_connection.return_value = mocked_connection + mocked_response = Mock(HTTPResponse) + mocked_connection.getresponse.return_value = mocked_response + mocked_response.status = '200' + mocked_response.headers = {} + + client = SimulatorClient('localhost') + client.edit_configuration("sample_url") + + mocked_connection.close.assert_called_with() + mocked_connection.request.assert_called_with('PUT', '/simulator/config', + body=json.dumps({"vesServerUrl": "sample_url"}), + headers={'Content-Type': 'application/json'}) + + @patch('cli.nf_simulator.http.client.HTTPConnection') + def test_should_properly_retrieve_configuration(self, http_connection): + mocked_connection = Mock(HTTPConnection) + http_connection.return_value = mocked_connection + mocked_response = Mock(HTTPResponse) + mocked_connection.getresponse.return_value = mocked_response + mocked_response.status = '200' + mocked_response.headers = {} + + client = SimulatorClient('localhost') + client.get_configuration() + mocked_connection.close.assert_called_with() + mocked_connection.request.assert_called_with('GET', '/simulator/config') + + + @patch('cli.nf_simulator.http.client.HTTPConnection') + def test_should_properly_trigger_filter_template_action(self, http_connection): + request = '{"sampleSearchString": "sampleSearchValue"}' + mocked_connection = Mock(HTTPConnection) + http_connection.return_value = mocked_connection + mocked_response = Mock(HTTPResponse) + mocked_connection.getresponse.return_value = mocked_response + mocked_response.status = '200' + mocked_response.headers = {} + + client = SimulatorClient('localhost') + client.search_for_templates(request) + + mocked_connection.close.assert_called_with() + mocked_connection.request.assert_called_with('POST', '/template/search', + body=json.dumps({"searchExpr": {"sampleSearchString": "sampleSearchValue"}}), + headers={'Content-Type': 'application/json'}) + + + @classmethod + def _create_request(cls): + return PersistedEventRequest(SimulatorParams(), 'sample_template') + + @classmethod + def _create_one_time_request(cls, event_filepath): + with open(event_filepath) as json_event: + event_body = json.load(json_event) + return FullEventRequest(event_body, 'sample_url') diff --git a/simulator-cli/tests/test_tailf_client.py b/simulator-cli/tests/test_tailf_client.py new file mode 100644 index 0000000..e5b83fc --- /dev/null +++ b/simulator-cli/tests/test_tailf_client.py @@ -0,0 +1,47 @@ +### +# ============LICENSE_START======================================================= +# Simulator +# ================================================================================ +# Copyright (C) 2021 Nokia. All rights reserved. +# ================================================================================ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============LICENSE_END========================================================= +### +import unittest +import asynctest + +from cli.client.tailf_client import TailfClient + + +class TestTailfClient(unittest.TestCase): + + def __init__(self, methodName='runTest'): + super().__init__(methodName) + self._client = TailfClient('ws://localhost:9999') + + @asynctest.mock.patch('cli.client.tailf_client.websockets') + def test_should_connect_to_server_and_receive_message(self, websockets_mock): + recv_mock = asynctest.CoroutineMock(side_effect=self.interrupt) + aenter_mock = asynctest.MagicMock() + connection_mock = asynctest.MagicMock() + websockets_mock.connect.return_value = aenter_mock + aenter_mock.__aenter__.return_value = connection_mock + connection_mock.recv = recv_mock + + self._client.tailf_messages() + + recv_mock.assert_awaited_once() + + def interrupt(self): + self._client._is_running = False + return 'test' diff --git a/simulator-cli/tox.ini b/simulator-cli/tox.ini new file mode 100644 index 0000000..e6c360f --- /dev/null +++ b/simulator-cli/tox.ini @@ -0,0 +1,10 @@ +[tox] +envlist = pytest +skipsdist = true + +[testenv] +basepython = python3 +deps = -rrequirements.txt + +[testenv:pytest] +commands = pytest -v |