summaryrefslogtreecommitdiffstats
path: root/python-discovery-client
diff options
context:
space:
mode:
Diffstat (limited to 'python-discovery-client')
-rw-r--r--python-discovery-client/.gitignore62
-rw-r--r--python-discovery-client/ChangeLog.md17
-rw-r--r--python-discovery-client/LICENSE.txt32
-rw-r--r--python-discovery-client/MANIFEST.in1
-rw-r--r--python-discovery-client/README.md2
-rw-r--r--python-discovery-client/discovery_client/__init__.py21
-rw-r--r--python-discovery-client/discovery_client/discovery.py368
-rw-r--r--python-discovery-client/discovery_client/util.py78
-rw-r--r--python-discovery-client/requirements.txt3
-rw-r--r--python-discovery-client/setup.py35
-rw-r--r--python-discovery-client/tests/test_discovery.py253
-rw-r--r--python-discovery-client/tests/test_util.py59
12 files changed, 931 insertions, 0 deletions
diff --git a/python-discovery-client/.gitignore b/python-discovery-client/.gitignore
new file mode 100644
index 0000000..1dbc687
--- /dev/null
+++ b/python-discovery-client/.gitignore
@@ -0,0 +1,62 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+env/
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*,cover
+.hypothesis/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+#Ipython Notebook
+.ipynb_checkpoints
diff --git a/python-discovery-client/ChangeLog.md b/python-discovery-client/ChangeLog.md
new file mode 100644
index 0000000..da45e86
--- /dev/null
+++ b/python-discovery-client/ChangeLog.md
@@ -0,0 +1,17 @@
+# Change Log
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](http://keepachangelog.com/)
+and this project adheres to [Semantic Versioning](http://semver.org/).
+
+## [2.1.0]
+
+* Update `get_configuration` to use the config binding service and also use the `CONSUL_HOST` environment variable
+
+## [2.0.0]
+
+* Added public `resolve_name` method used to resolve names by looking up in Consul
+* Changed `_resolve_name` to return lists over an arbitrary entry from the list
+* Changed the `register_for_discovery` method to pass in service ip and avoid unimpressive auto ip discovery
+* Changed setup.py to use abstract requirements to be a more friendly library
diff --git a/python-discovery-client/LICENSE.txt b/python-discovery-client/LICENSE.txt
new file mode 100644
index 0000000..cb8008a
--- /dev/null
+++ b/python-discovery-client/LICENSE.txt
@@ -0,0 +1,32 @@
+============LICENSE_START=======================================================
+org.onap.dcae
+================================================================================
+Copyright (c) 2017 AT&T Intellectual Property. 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=========================================================
+
+ECOMP is a trademark and service mark of AT&T Intellectual Property.
+
+
+Copyright (c) 2017 AT&T Intellectual Property. All rights reserved.
+===================================================================
+Licensed under the Creative Commons License, Attribution 4.0 Intl. (the "License");
+you may not use this documentation except in compliance with the License.
+You may obtain a copy of the License at
+ https://creativecommons.org/licenses/by/4.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.
diff --git a/python-discovery-client/MANIFEST.in b/python-discovery-client/MANIFEST.in
new file mode 100644
index 0000000..f9bd145
--- /dev/null
+++ b/python-discovery-client/MANIFEST.in
@@ -0,0 +1 @@
+include requirements.txt
diff --git a/python-discovery-client/README.md b/python-discovery-client/README.md
new file mode 100644
index 0000000..b022f5d
--- /dev/null
+++ b/python-discovery-client/README.md
@@ -0,0 +1,2 @@
+# python-discovery-client
+Python client to be used by service components for discovery
diff --git a/python-discovery-client/discovery_client/__init__.py b/python-discovery-client/discovery_client/__init__.py
new file mode 100644
index 0000000..9e0358a
--- /dev/null
+++ b/python-discovery-client/discovery_client/__init__.py
@@ -0,0 +1,21 @@
+# org.onap.dcae
+# ================================================================================
+# Copyright (c) 2017 AT&T Intellectual Property. 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=========================================================
+#
+# ECOMP is a trademark and service mark of AT&T Intellectual Property.
+
+from .discovery import get_service_name, get_configuration, \
+ register_for_discovery, resolve_name
diff --git a/python-discovery-client/discovery_client/discovery.py b/python-discovery-client/discovery_client/discovery.py
new file mode 100644
index 0000000..180e933
--- /dev/null
+++ b/python-discovery-client/discovery_client/discovery.py
@@ -0,0 +1,368 @@
+# org.onap.dcae
+# ================================================================================
+# Copyright (c) 2017 AT&T Intellectual Property. 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=========================================================
+#
+# ECOMP is a trademark and service mark of AT&T Intellectual Property.
+
+import time, json, os, re, logging
+from itertools import chain
+from functools import partial
+import requests
+import consul
+import six
+from discovery_client import util
+
+
+_logger = util.get_logger(__name__)
+
+class DiscoveryInitError(RuntimeError):
+ pass
+
+class DiscoveryRegistrationError(RuntimeError):
+ pass
+
+class DiscoveryResolvingNameError(RuntimeError):
+ pass
+
+
+#####
+# Consul calls for services
+#####
+
+def _get_configuration_from_consul(consul_handle, service_name):
+ index = None
+ while True:
+ index, data = consul_handle.kv.get(service_name, index=index)
+
+ if data:
+ return json.loads(data["Value"].decode("utf-8"))
+ else:
+ _logger.warn("No configuration found for {0}. Try again in a bit."
+ .format(service_name))
+ time.sleep(5)
+
+def _get_relationships_from_consul(consul_handle, service_name):
+ """Fetch the relationship information from Consul for a service by service
+ name. Returns a list of service names."""
+ index = None
+ rel_key = "{0}:rel".format(service_name)
+ while True:
+ index, data = consul_handle.kv.get(rel_key, index=index)
+
+ if data:
+ return json.loads(data["Value"].decode("utf-8"))
+ else:
+ _logger.warn("No relationships found for {0}. Try again in a bit."
+ .format(service_name))
+ time.sleep(5)
+
+def _lookup_with_consul(consul_handle, service_name, max_attempts=0):
+ num_attempts = 1
+
+ while True:
+ index, results = consul_handle.catalog.service(service_name)
+
+ if results:
+ return results
+ else:
+ num_attempts += 1
+
+ if max_attempts > 0 and max_attempts < num_attempts:
+ return None
+
+ _logger.warn("Service not found {0}. Trying again in a bit."
+ .format(service_name))
+ time.sleep(5)
+
+def _register_with_consul(consul_handle, service_name, service_ip, service_port,
+ health_endpoint):
+ # https://www.consul.io/docs/agent/http/agent.html#agent_service_register
+ # Note: Unhealthy services should not return in queries i.e.
+ # dig @127.0.0.1 -p 8600 foo.service.consul
+ health_url = "http://{0}:{1}/{2}".format(service_ip, service_port, health_endpoint)
+ return consul_handle.agent.service.register(service_name, address=service_ip,
+ port=service_port, check= { "HTTP": health_url, "Interval": "5s" })
+
+#####
+# Config binding service call
+#####
+
+def _get_configuration_resolved_from_cbs(consul_handle, service_name):
+ """
+ This is what a minimal python client library that wraps the CBS would look like.
+ POSSIBLE TODO: break this out into pypi repo
+
+ This call does not raise an exception if Consul or the CBS cannot complete the request.
+ It logs an error and returns {} if the config is not bindable.
+ It could be a temporary network outage. Call me again later.
+
+ It will raise an exception if the necessary env parameters were not set because that is irrecoverable.
+ This function is called in my /heatlhcheck, so this will be caught early.
+ """
+ config = {}
+
+ results = _lookup_with_consul(consul_handle, "config_binding_service",
+ max_attempts=5)
+
+ if results is None:
+ logger.error("Cannot bind config at this time, cbs is unreachable")
+ else:
+ cbs_hostname = results[0]["ServiceAddress"]
+ cbs_port = results[0]["ServicePort"]
+ cbs_url = "http://{hostname}:{port}".format(hostname=cbs_hostname, port=cbs_port)
+
+ #get my config
+ my_config_endpoint = "{0}/service_component/{1}".format(cbs_url,
+ service_name)
+ res = requests.get(my_config_endpoint)
+ try:
+ res.raise_for_status()
+ config = res.json()
+ _logger.info("get_config returned the following configuration: {0}".format(json.dumps(config)))
+ except:
+ _logger.error("in get_config, the config binding service endpoint {0} blew up on me. Error code: {1}, Error text: {2}".format(my_config_endpoint, res.status_code, res.text))
+ return config
+
+#####
+# Functionality for putting together service's configuration
+#####
+
+def _get_connection_types(config):
+ """Get all the connection types for a given configuration json
+
+ Crawls through the entire config dict recursively and returns the entries
+ that have been identified as service connections in the form of a list of tuples -
+
+ [(config key, component type), ..]
+
+ where "config key" is a compound key in the form of a tuple. Each entry in
+ the compound key is a key to a level within the json data structure."""
+ def grab_component_type(v):
+ # To support Python2, unicode strings are not type `str`. Specifically,
+ # the config string values from Consul maybe encoded to utf-8 so better
+ # be prepared.
+ if isinstance(v, six.string_types):
+ # Regex matches on strings like "{{foo}}" and "{{ BAR }}" and
+ # extracts the alphanumeric string inside the parantheses.
+ result = re.match("^{{\s*([-_.\w]*)\s*}}", v)
+ return result.group(1) if result else None
+
+ def crawl(config, parent_key=()):
+ if isinstance(config, dict):
+ rels = [ crawl(value, parent_key + (key, ))
+ for key, value in config.items() ]
+ rels = chain(*rels)
+ elif isinstance(config, list):
+ rels = [ crawl(config[index], parent_key + (index, ))
+ for index in range(0, len(config)) ]
+ rels = chain(*rels)
+ else:
+ rels = [(parent_key, grab_component_type(config))]
+
+ # Filter out the entries with Nones
+ rels = [(key, rel) for key, rel in rels if rel]
+ return rels
+
+ return crawl(config)
+
+def _has_connections(config):
+ return True if _get_connection_types(config) else False
+
+def _resolve_connection_types(service_name, connection_types, relationships):
+
+ def find_match(connection_type):
+ ret_list = []
+ for rel in relationships:
+ if connection_type in rel:
+ ret_list.append(rel)
+ return ret_list
+
+ return [ (key, find_match(connection_type))
+ for key, connection_type in connection_types ]
+
+def _resolve_name(lookup_func, service_name):
+ """Resolves the service component name to detailed connection information
+
+ Currently this is grouped into two ways:
+ 1. CDAP applications take a two step approach - call Consul then call the
+ CDAP broker
+ 2. All other applications just call Consul to get IP and port
+
+ Args:
+ ----
+ lookup_func: fn(string) -> list of dicts
+ The function should return a list of dicts that have "ServiceAddress" and
+ "ServicePort" key value entries
+ service_name: (string) service name to lookup
+
+ Return depends upon the connection type:
+ 1. CDAP applications return a dict
+ 2. All other applications return a string
+ """
+ def handle_result(result):
+ ip = result["ServiceAddress"]
+ port = result["ServicePort"]
+
+ if not (ip and port):
+ raise DiscoveryResolvingNameError(
+ "Failed to resolve name for {0}: ip, port not set".format(service_name))
+
+ # TODO: Need a better way to identify CDAP apps. Really need to make this
+ # better.
+ if "platform-" in service_name:
+ return "{0}:{1}".format(ip, port)
+ elif "cdap" in service_name:
+ redirectish_url = "http://{0}:{1}/application/{2}".format(ip, port,
+ service_name)
+
+ r = requests.get(redirectish_url)
+ r.raise_for_status()
+ details = r.json()
+ # Pick out the details to expose to the component developers
+ return { key: details[key]
+ for key in ["connectionurl", "serviceendpoints"] }
+ else:
+ return "{0}:{1}".format(ip, port)
+
+ try:
+ results = lookup_func(service_name)
+ return [ handle_result(result) for result in results ]
+ except Exception as e:
+ raise DiscoveryResolvingNameError(
+ "Failed to resolve name for {0}: {1}".format(service_name, e))
+
+def _resolve_configuration_dict(ch, service_name, config):
+ """
+ Helper used by both resolve_configuration_dict and get_configuration
+ """
+ if _has_connections(config):
+ rels = _get_relationships_from_consul(ch, service_name)
+ connection_types = _get_connection_types(config)
+ connection_names = _resolve_connection_types(service_name, connection_types, rels)
+ # NOTE: The hardcoded use of the first element. This is to keep things backwards
+ # compatible since resolve name now returns a list.
+ for key, conn in [(key, [_resolve_name(partial(_lookup_with_consul, ch), name)[0] for name in names]) for key, names in connection_names]:
+ config = util.update_json(config, key, conn)
+
+ _logger.info("Generated config: {0}".format(config))
+ return config
+
+#####
+# Public calls
+#####
+
+def get_consul_hostname(consul_hostname_override=None):
+ """Get the Consul hostname"""
+ try:
+ return consul_hostname_override \
+ if consul_hostname_override else os.environ["CONSUL_HOST"]
+ except:
+ raise DiscoveryInitError("CONSUL_HOST variable has not been set!")
+
+def get_service_name():
+ """Get the full service name
+
+ This is expected to be given from whatever entity is starting this service
+ and given by an environment variable called "HOSTNAME"."""
+ try:
+ return os.environ["HOSTNAME"]
+ except:
+ raise DiscoveryInitError("HOSTNAME variable has not been set!")
+
+
+def resolve_name(consul_host, service_name, max_attempts=3):
+ """Resolve the service name
+
+ Do a service discovery lookup from Consul and return back the detailed connection
+ information.
+
+ Returns:
+ --------
+ For CDAP apps, returns a dict. All others a string with the format "<ip>:<port>"
+ """
+ ch = consul.Consul(host=consul_host)
+ lookup_func = partial(_lookup_with_consul, ch, max_attempts=max_attempts)
+ return _resolve_name(lookup_func, service_name)
+
+
+def resolve_configuration_dict(consul_host, service_name, config):
+ """
+ Utility method for taking a given service_name, and config dict, and resolving it
+ """
+ ch = consul.Consul(host=consul_host)
+ return _resolve_configuration_dict(ch, service_name, config)
+
+
+def get_configuration(override_consul_hostname=None, override_service_name=None,
+ from_cbs=True):
+ """Provides this service component's configuration information fully resolved
+
+ This method can either resolve the configuration locally here or make a
+ remote call to the config binding service. The default is to use the config
+ binding service.
+
+ Args:
+ -----
+ override_consul_hostname (string): Consul hostname to use rather than the one
+ set by the environment variable CONSUL_HOST
+ override_service_name (string): Use this name over the name set on the
+ HOSTNAME environment variable. Default is None.
+ from_cbs (boolean): True (default) means use the config binding service otherwise
+ set to False to have the config pulled and resolved by this library
+
+ Returns the fully resolved service component configuration as a dict
+ """
+ # Get config, bootstrap
+ consul_hostname = get_consul_hostname(override_consul_hostname)
+ # NOTE: We use the default port 8500
+ ch = consul.Consul(host=consul_hostname)
+ service_name = override_service_name if override_service_name else get_service_name()
+ _logger.info("service name: {0}".format(service_name))
+
+ if from_cbs:
+ return _get_configuration_resolved_from_cbs(ch, service_name)
+ else:
+ # The following will happen:
+ #
+ # 1. Fetching the configuration by service component name from Consul
+ # 2. Fetching the relationships for this service component by service component
+ # name
+ # 3. Pick out the connection types from the templetized fields in the configuration
+ # 4. Resolve the connection types with connection names using the step #2
+ # information
+ # 5. Resolve the connection names with the actual connection via queries to
+ # Consul using the connection name
+ config = _get_configuration_from_consul(ch, service_name)
+ return _resolve_configuration_dict(ch, service_name, config)
+
+
+def register_for_discovery(consul_host, service_ip, service_port):
+ """Register the service component for service discovery
+
+ This is required in order for other services to "discover" you so that you
+ can service their requests.
+
+ NOTE: Applications may not need to make this call depending upon if the
+ environment is using Registrator.
+ """
+ ch = consul.Consul(host=consul_host)
+ service_name = get_service_name()
+
+ if _register_with_consul(ch, service_name, service_ip, service_port, "health"):
+ _logger.info("Registered to consul: {0}".format(service_name))
+ else:
+ _logger.error("Failed to register to consul: {0}".format(service_name))
+ raise DiscoveryRegistrationError()
diff --git a/python-discovery-client/discovery_client/util.py b/python-discovery-client/discovery_client/util.py
new file mode 100644
index 0000000..b59647a
--- /dev/null
+++ b/python-discovery-client/discovery_client/util.py
@@ -0,0 +1,78 @@
+# org.onap.dcae
+# ================================================================================
+# Copyright (c) 2017 AT&T Intellectual Property. 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=========================================================
+#
+# ECOMP is a trademark and service mark of AT&T Intellectual Property.
+
+import collections
+import logging, sys
+import six
+
+#####
+# Module contains utility methods
+#####
+
+def update_json(src, key, value):
+ """Updates a nested JSON value
+
+ This method does a recursive lookup for a value given a compound key and then
+ replaces that value with the passed in new value.
+
+ For example, given a src json { "a": [ { "aa": 1 }, "foo" ], "b": "2 } and a
+ key ("a", 0, "aa"), the value parameter would replace 1.
+
+ :param src: json to update
+ :type src: dict or list
+ :param key: compound key used to lookup
+ :type key: tuple
+ :param value: new value used to replace
+ :type value: object
+
+ :return: updated json
+ """
+ if key:
+ src[key[0]] = update_json(src[key[0]], key[1:], value)
+ else:
+ # We've found the value we want to replace regardless of whether or not
+ # the object we are replacing is another copmlicated data structure.
+ src = value
+ return src
+
+def _has_handlers(logger):
+ """Check if logger has handlers"""
+ if six.PY3:
+ return logger.hasHandlers()
+ else:
+ # TODO: Not sure how to check if a handler has already been attached
+ # WATCH: Downside is lines get printed multiple times
+ return False
+
+def get_logger(name, level=logging.INFO):
+ """Get a logger with sensible defaults
+
+ This method returns a logger from logging by name that has been set with sensible
+ defaults if the logger hasn't already been setup with any handlers. The
+ default handler is a stream handler to stdout.
+ """
+ logger = logging.getLogger(name)
+
+ if not _has_handlers(logger):
+ # No handlers attached which means logging hasn't been setup. Set
+ # "sensible" defaults which means stdout, INFO
+ logger.setLevel(level)
+ logger.addHandler(logging.StreamHandler(stream=sys.stdout))
+
+ return logger
diff --git a/python-discovery-client/requirements.txt b/python-discovery-client/requirements.txt
new file mode 100644
index 0000000..f79aa82
--- /dev/null
+++ b/python-discovery-client/requirements.txt
@@ -0,0 +1,3 @@
+python-consul==0.6.1
+requests==2.11.1
+six==1.10.0
diff --git a/python-discovery-client/setup.py b/python-discovery-client/setup.py
new file mode 100644
index 0000000..2575358
--- /dev/null
+++ b/python-discovery-client/setup.py
@@ -0,0 +1,35 @@
+# org.onap.dcae
+# ================================================================================
+# Copyright (c) 2017 AT&T Intellectual Property. 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=========================================================
+#
+# ECOMP is a trademark and service mark of AT&T Intellectual Property.
+
+from setuptools import setup, find_packages
+from pip.req import parse_requirements
+from pip.download import PipSession
+
+setup(
+ name = "python-discovery-client",
+ version = "2.1.0",
+ packages = find_packages(),
+ author = "Michael Hwang",
+ email="dcae@lists.openecomp.org",
+ description = ("Python client to be used by service components for discovery"),
+ install_requires = [
+ 'python-consul>=0.6.0,<1.0.0',
+ 'requests>=2.11.0,<3.0.0',
+ 'six>=1.10.0,<2.0.0']
+ )
diff --git a/python-discovery-client/tests/test_discovery.py b/python-discovery-client/tests/test_discovery.py
new file mode 100644
index 0000000..00f1b5e
--- /dev/null
+++ b/python-discovery-client/tests/test_discovery.py
@@ -0,0 +1,253 @@
+# org.onap.dcae
+# ================================================================================
+# Copyright (c) 2017 AT&T Intellectual Property. 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=========================================================
+#
+# ECOMP is a trademark and service mark of AT&T Intellectual Property.
+
+import json, logging
+# http://stackoverflow.com/questions/9623114/check-if-two-unordered-lists-are-equal
+from collections import Counter
+from functools import partial
+import pytest
+import requests
+from discovery_client import discovery as dis
+
+
+def test_get_connection_types():
+ config = { "x": "say something", "y": 123, "z": "{{some-analytics}}" }
+ expected = [(("z", ), "some-analytics"), ]
+ actual = dis._get_connection_types(config)
+ assert Counter(expected) == Counter(actual)
+
+ # Whitespaces ok
+ config = { "x": "say something", "y": 123, "z": "{{ some-analytics }}" }
+ expected = [(("z", ), "some-analytics"), ]
+ actual = dis._get_connection_types(config)
+ assert Counter(expected) == Counter(actual)
+
+ # Paul wanted the ability to include version so match on more than just one
+ # subfield
+ config = { "x": "say something", "y": 123, "z": "{{1-0-0.some-analytics}}" }
+ expected = [(("z", ), "1-0-0.some-analytics"), ]
+ actual = dis._get_connection_types(config)
+ assert Counter(expected) == Counter(actual)
+
+ # Need double parantheses
+ config = { "x": "say something", "y": 123, "z": "{some-analytics}" }
+ actual = dis._get_connection_types(config)
+ assert Counter([]) == Counter(actual)
+
+ # Nested in dict dict
+ config = { "x": "say something", "y": 123,
+ "z": { "aa": { "bbb": "{{some-analytics}}" } } }
+ expected = [(("z", "aa", "bbb"), "some-analytics"), ]
+ actual = dis._get_connection_types(config)
+ assert Counter(expected) == Counter(actual)
+
+ # Nested in list dict
+ config = { "x": "say something", "y": 123,
+ "z": [ "no-op", { "bbb": "{{some-analytics}}" } ] }
+ expected = [(("z", 1, "bbb"), "some-analytics"), ]
+ actual = dis._get_connection_types(config)
+ assert Counter(expected) == Counter(actual)
+
+ # Force strings to be unicode, test for Python2 compatibility
+ config = { "x": "say something".decode("utf-8"), "y": 123,
+ "z": "{{some-analytics}}".decode("utf-8") }
+ expected = [(("z", ), "some-analytics"), ]
+ actual = dis._get_connection_types(config)
+ assert Counter(expected) == Counter(actual)
+
+
+def test_resolve_connection_types():
+ upstream = "b243b0b8-8a24-4f88-add7-9b530c578149.laika.foobar.rework-central.dcae.ecomp.com"
+ downstream = "839b0b31-f13d-4bfc-9adf-450d34071304.laika.foobar.rework-central.dcae.ecomp.com"
+
+ connection_types = [("downstream-laika", "laika"),]
+ relationships = [downstream]
+ expected = [("downstream-laika", [downstream])]
+ actual = dis._resolve_connection_types(upstream, connection_types, relationships)
+ assert sorted(actual) == sorted(expected)
+
+ # NOTE: Removed test that tested the scenario where the name stems don't
+ # match up. This name stem matching was causing grief to others so lifted the
+ # constraint.
+
+
+def test_resolve_name_for_platform():
+ def fake_lookup(fixture, service_name):
+ if service_name == fixture["ServiceName"]:
+ return [fixture]
+
+ # Good case. Grabbed from Consul call
+ fixture = { 'Node': 'agent-one', 'ModifyIndex': 2892, 'Address': '127.0.0.1',
+ 'ServiceName': 'e9526e08-f9b8-42c4-99a3-443cd4deeac1.platform-laika.foobar.rework-central.dcae.ecomp.com',
+ 'ServicePort': 12708, 'CreateIndex': 2825, 'ServiceAddress': '196.207.143.67',
+ 'ServiceTags': [], 'ServiceEnableTagOverride': False,
+ 'ServiceID': 'e9526e08-f9b8-42c4-99a3-443cd4deeac1.platform-laika.foobar.rework-central.dcae.ecomp.com'}
+
+ expected = ["{0}:{1}".format(fixture["ServiceAddress"],
+ fixture["ServicePort"])]
+ assert dis._resolve_name(partial(fake_lookup, fixture), fixture["ServiceName"]) == expected
+
+ # Fail case. When Registrator is misconfigured and ServiceAddress is not set
+ fixture = { 'Node': 'agent-one', 'ModifyIndex': 2892, 'Address': '127.0.0.1',
+ 'ServiceName': 'e9526e08-f9b8-42c4-99a3-443cd4deeac1.platform-laika.foobar.rework-central.dcae.ecomp.com',
+ 'ServicePort': 12708, 'CreateIndex': 2825, 'ServiceAddress': '',
+ 'ServiceTags': [], 'ServiceEnableTagOverride': False,
+ 'ServiceID': 'e9526e08-f9b8-42c4-99a3-443cd4deeac1.platform-laika.foobar.rework-central.dcae.ecomp.com'}
+
+ with pytest.raises(dis.DiscoveryResolvingNameError):
+ dis._resolve_name(partial(fake_lookup, fixture), fixture["ServiceName"])
+
+ # Fail case. When lookup just blows up for some reason
+ def fake_lookup_blows(service_name):
+ raise RuntimeError("Thar she blows")
+
+ with pytest.raises(dis.DiscoveryResolvingNameError):
+ dis._resolve_name(fake_lookup_blows, fixture["ServiceName"])
+
+
+def test_resolve_name_for_docker():
+ def fake_lookup(fixture, service_name):
+ if service_name == fixture["ServiceName"]:
+ return [fixture]
+
+ # Good case. Grabbed from Consul call
+ fixture = { 'Node': 'agent-one', 'ModifyIndex': 2892, 'Address': '127.0.0.1',
+ 'ServiceName': 'e9526e08-f9b8-42c4-99a3-443cd4deeac1.laika.foobar.rework-central.dcae.ecomp.com',
+ 'ServicePort': 12708, 'CreateIndex': 2825, 'ServiceAddress': '196.207.143.67',
+ 'ServiceTags': [], 'ServiceEnableTagOverride': False,
+ 'ServiceID': 'e9526e08-f9b8-42c4-99a3-443cd4deeac1.laika.foobar.rework-central.dcae.ecomp.com'}
+
+ expected = ["{0}:{1}".format(fixture["ServiceAddress"],
+ fixture["ServicePort"])]
+ assert dis._resolve_name(partial(fake_lookup, fixture), fixture["ServiceName"]) == expected
+
+ # Fail case. When Registrator is misconfigured and ServiceAddress is not set
+ fixture = { 'Node': 'agent-one', 'ModifyIndex': 2892, 'Address': '127.0.0.1',
+ 'ServiceName': 'e9526e08-f9b8-42c4-99a3-443cd4deeac1.laika.foobar.rework-central.dcae.ecomp.com',
+ 'ServicePort': 12708, 'CreateIndex': 2825, 'ServiceAddress': '',
+ 'ServiceTags': [], 'ServiceEnableTagOverride': False,
+ 'ServiceID': 'e9526e08-f9b8-42c4-99a3-443cd4deeac1.laika.foobar.rework-central.dcae.ecomp.com'}
+
+ with pytest.raises(dis.DiscoveryResolvingNameError):
+ dis._resolve_name(partial(fake_lookup, fixture), fixture["ServiceName"])
+
+ # Fail case. When lookup just blows up for some reason
+ def fake_lookup_blows(service_name):
+ raise RuntimeError("Thar she blows")
+
+ with pytest.raises(dis.DiscoveryResolvingNameError):
+ dis._resolve_name(fake_lookup_blows, fixture["ServiceName"])
+
+
+def test_resolve_name_for_cdap(monkeypatch):
+ def fake_lookup(fixture, service_name):
+ if service_name == fixture["ServiceName"]:
+ return [fixture]
+
+ # Good case. Handle CDAP apps
+ fixture = {
+ "Node":"agent-one", "Address":"10.170.2.17",
+ "ServiceID": "00b6210b71e445cdaadf76e620ebffcfhelloworldcdapappfoobardcaereworkdcaeecompcom",
+ "ServiceName": "00b6210b71e445cdaadf76e620ebffcfhelloworldcdapappfoobardcaereworkdcaeecompcom",
+ "ServiceTags":[],
+ "ServiceAddress": "196.207.143.116",
+ "ServicePort": 7777, "ServiceEnableTagOverride": False, "CreateIndex": 144733, "ModifyIndex":145169 }
+
+ class FakeRequestsResponse(object):
+ def __init__(self, url, broker_json):
+ self.url = url
+ self.broker_json = broker_json
+
+ def raise_for_status(self):
+ expected_broker_url = "http://{0}:{1}/application/{2}".format(
+ fixture["ServiceAddress"], fixture["ServicePort"],
+ fixture["ServiceName"])
+ if self.url == expected_broker_url:
+ return True
+ else:
+ raise RuntimeError("Mismatching address")
+
+ def json(self):
+ return self.broker_json
+
+ # Simulate the call to the CDAP broker
+ broker_json = {
+ "appname":"00b6210b71e445cdaadf76e620ebffcfhelloworldcdapappfoobardcaereworkdcaeecompcom",
+ "healthcheckurl":"http://196.207.143.116:7777/application/00b6210b71e445cdaadf76e620ebffcfhelloworldcdapappfoobardcaereworkdcaeecompcom/healthcheck",
+ "metricsurl":"http://196.207.143.116:7777/application/00b6210b71e445cdaadf76e620ebffcfhelloworldcdapappfoobardcaereworkdcaeecompcom/metrics",
+ "url":"http://196.207.143.116:7777/application/00b6210b71e445cdaadf76e620ebffcfhelloworldcdapappfoobardcaereworkdcaeecompcom",
+ "connectionurl":"http://196.207.160.159:10000/v3/namespaces/default/streams/foo",
+ "serviceendpoints": "something" }
+
+ monkeypatch.setattr(requests, "get", lambda url: FakeRequestsResponse(url, broker_json))
+
+ expected = [{ key: broker_json[key]
+ for key in ["connectionurl", "serviceendpoints"] }]
+ assert dis._resolve_name(partial(fake_lookup, fixture), fixture["ServiceName"]) == expected
+
+
+def test_resolve_configuration_dict(monkeypatch):
+ service_name = "123.current-node-type.some-service.some-location.com"
+ target_service_name = "456.target-node-type.some-service.some-location.com"
+
+ # Fake the Consul calls
+
+ def fake_get_relationship(ch, service_name):
+ return [ target_service_name ]
+
+ monkeypatch.setattr(dis, "_get_relationships_from_consul",
+ fake_get_relationship)
+
+ fixture = [{ 'Node': 'agent-one', 'ModifyIndex': 2892, 'Address': '127.0.0.1',
+ 'ServiceName': target_service_name,
+ 'ServicePort': 12708, 'CreateIndex': 2825, 'ServiceAddress': '196.207.143.67',
+ 'ServiceTags': [], 'ServiceEnableTagOverride': False,
+ 'ServiceID': target_service_name }]
+
+ def fake_lookup(ch, service_name):
+ return fixture
+
+ monkeypatch.setattr(dis, "_lookup_with_consul", fake_lookup)
+
+ # Simple config case
+ test_config = { "target-node": "{{ target-node-type }}", "other-param": 123 }
+
+ expected = dict(test_config)
+ expected["target-node"] = ["196.207.143.67:12708"]
+ actual = dis._resolve_configuration_dict(None, service_name, test_config)
+ assert Counter(actual) == Counter(expected)
+
+ # Nested config case
+ test_config = { "output_formats": { "target-node": "{{ target-node-type }}" },
+ "other-param": 123 }
+
+ expected = dict(test_config)
+ expected["output_formats"]["target-node"] = "196.207.143.67:12708"
+ actual = dis._resolve_configuration_dict(None, service_name, test_config)
+ assert Counter(actual) == Counter(expected)
+
+
+def test_get_consul_host(monkeypatch):
+ with pytest.raises(dis.DiscoveryInitError):
+ dis.get_consul_hostname()
+
+ monkeypatch.setenv("CONSUL_HOST", "i-am-consul-host")
+ assert "i-am-consul-host" == dis.get_consul_hostname()
+
+ assert "no-i-am" == dis.get_consul_hostname("no-i-am")
diff --git a/python-discovery-client/tests/test_util.py b/python-discovery-client/tests/test_util.py
new file mode 100644
index 0000000..8256359
--- /dev/null
+++ b/python-discovery-client/tests/test_util.py
@@ -0,0 +1,59 @@
+# org.onap.dcae
+# ================================================================================
+# Copyright (c) 2017 AT&T Intellectual Property. 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=========================================================
+#
+# ECOMP is a trademark and service mark of AT&T Intellectual Property.
+
+from collections import Counter
+from discovery_client import util
+
+
+def test_update_json():
+ # Simple
+ test_json = { "a": "{ funk }", "b": "spring" }
+ test_key = ("a", )
+ test_value = "funk is alive"
+ expected = dict(test_json)
+ expected["a"] = test_value
+ actual = util.update_json(test_json, test_key, test_value)
+ assert Counter(expected) == Counter(actual)
+
+ # Nothing to replace, key is empty which translates to repalce the entire
+ # json
+ test_key = ()
+ expected = test_value
+ actual = util.update_json(test_json, test_key, test_value)
+ assert Counter(expected) == Counter(actual)
+
+ # Nested in dicts
+ test_json = { "a": { "aa": { "aaa": "{ funk }", "bbb": "fall" },
+ "bb": "summer" }, "b": "spring" }
+ test_key = ("a", "aa", "aaa")
+ test_value = "funk is alive"
+ expected = dict(test_json)
+ expected["a"]["aa"]["aaa"] = test_value
+ actual = util.update_json(test_json, test_key, test_value)
+ assert Counter(expected) == Counter(actual)
+
+ # Nested in dict list
+ test_json = { "a": { "aa": [ 123, { "aaa": "{ funk }", "bbb": "fall" } ],
+ "bb": "summer" }, "b": "spring" }
+ test_key = ("a", "aa", 1, "aaa")
+ test_value = "funk is alive"
+ expected = dict(test_json)
+ expected["a"]["aa"][1]["aaa"] = test_value
+ actual = util.update_json(test_json, test_key, test_value)
+ assert Counter(expected) == Counter(actual)