diff options
Diffstat (limited to 'python-discovery-client')
-rw-r--r-- | python-discovery-client/.gitignore | 62 | ||||
-rw-r--r-- | python-discovery-client/ChangeLog.md | 17 | ||||
-rw-r--r-- | python-discovery-client/LICENSE.txt | 32 | ||||
-rw-r--r-- | python-discovery-client/MANIFEST.in | 1 | ||||
-rw-r--r-- | python-discovery-client/README.md | 2 | ||||
-rw-r--r-- | python-discovery-client/discovery_client/__init__.py | 21 | ||||
-rw-r--r-- | python-discovery-client/discovery_client/discovery.py | 368 | ||||
-rw-r--r-- | python-discovery-client/discovery_client/util.py | 78 | ||||
-rw-r--r-- | python-discovery-client/requirements.txt | 3 | ||||
-rw-r--r-- | python-discovery-client/setup.py | 35 | ||||
-rw-r--r-- | python-discovery-client/tests/test_discovery.py | 253 | ||||
-rw-r--r-- | python-discovery-client/tests/test_util.py | 59 |
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) |