From 8984604d52cd9354897b48b783dfd8610c5c5758 Mon Sep 17 00:00:00 2001 From: Tommy Carpenter Date: Tue, 26 Jun 2018 22:19:08 -0400 Subject: Productionalize with NGINX, towards https Change-Id: I0fcb79216cfc83d817a8d0ac4f3817d0aeea4e95 Issue-ID: DCAEGEN2-562 Signed-off-by: Tommy Carpenter --- Changelog.md | 5 + Dockerfile | 16 +- MANIFEST.in | 1 - README.md | 29 ++- app/app/MANIFEST.in | 1 + app/app/__init__.py | 0 app/app/config_binding_service/__init__.py | 40 ++++ app/app/config_binding_service/client.py | 279 +++++++++++++++++++++++++++ app/app/config_binding_service/controller.py | 105 ++++++++++ app/app/config_binding_service/logging.py | 94 +++++++++ app/app/main.py | 32 +++ app/app/setup.py | 31 +++ app/app/swagger.yaml | 100 ++++++++++ app/app/tests/__init__.py | 21 ++ app/app/tests/conftest.py | 70 +++++++ app/app/tests/test_client.py | 159 +++++++++++++++ app/app/tests/test_controller.py | 113 +++++++++++ app/uwsgi.ini | 3 + config_binding_service/__init__.py | 40 ---- config_binding_service/client.py | 279 --------------------------- config_binding_service/controller.py | 105 ---------- config_binding_service/logging.py | 94 --------- config_binding_service/run.py | 33 ---- config_binding_service/swagger.yaml | 100 ---------- doc/cbs_diagram.png | Bin 72540 -> 0 bytes setup.py | 38 ---- tests/__init__.py | 21 -- tests/conftest.py | 70 ------- tests/test_client.py | 159 --------------- tests/test_controller.py | 113 ----------- tox-local.ini | 1 + tox.ini | 1 + 32 files changed, 1087 insertions(+), 1066 deletions(-) delete mode 100644 MANIFEST.in create mode 100644 app/app/MANIFEST.in create mode 100644 app/app/__init__.py create mode 100644 app/app/config_binding_service/__init__.py create mode 100644 app/app/config_binding_service/client.py create mode 100644 app/app/config_binding_service/controller.py create mode 100644 app/app/config_binding_service/logging.py create mode 100755 app/app/main.py create mode 100644 app/app/setup.py create mode 100644 app/app/swagger.yaml create mode 100644 app/app/tests/__init__.py create mode 100644 app/app/tests/conftest.py create mode 100644 app/app/tests/test_client.py create mode 100644 app/app/tests/test_controller.py create mode 100644 app/uwsgi.ini delete mode 100644 config_binding_service/__init__.py delete mode 100644 config_binding_service/client.py delete mode 100644 config_binding_service/controller.py delete mode 100644 config_binding_service/logging.py delete mode 100644 config_binding_service/run.py delete mode 100644 config_binding_service/swagger.yaml delete mode 100644 doc/cbs_diagram.png delete mode 100644 setup.py delete mode 100644 tests/__init__.py delete mode 100644 tests/conftest.py delete mode 100644 tests/test_client.py delete mode 100644 tests/test_controller.py diff --git a/Changelog.md b/Changelog.md index ae1ca69..d9c311e 100644 --- a/Changelog.md +++ b/Changelog.md @@ -4,6 +4,11 @@ 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.2.0] - 6/26/2018 +* Productionalize by moving to NGINX+UWSGI. Flask was not meant to be run as a production server +* This is towards HTTPS support, which will now be done via NGINX reverse proxying instead of in the application code itself +* The app structure has changed due to the project I am now using for this. See https://hub.docker.com/r/tiangolo/uwsgi-nginx-flask/ + ## [2.1.5] - 4/10/2018 * Fix a key where an invalid JSON in Consul blows up the CBS * Refactor the tests into smaller files diff --git a/Dockerfile b/Dockerfile index 7ca987f..df5a4f0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,13 @@ -FROM python:3.6 +FROM tiangolo/uwsgi-nginx-flask:python3.6 MAINTAINER tommy@research.att.com -ADD . /tmp +#setup uwsgi+nginx +# https://hub.docker.com/r/tiangolo/uwsgi-nginx-flask/ +COPY ./app /app -RUN pip install --upgrade pip -WORKDIR /tmp -#do the install -RUN pip install . - -EXPOSE 10000 +RUN pip install --upgrade pip +RUN pip install /app/app RUN mkdir -p /opt/logs/ -CMD run.py +ENV LISTEN_PORT 10000 diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index ad8b807..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -include config_binding_service/swagger.yaml diff --git a/README.md b/README.md index e430e22..a37303e 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,7 @@ # config_binding_service -# Interface Diagram -This repo is the thing in red: - -![Alt text](doc/cbs_diagram.png?raw=true) +# Changelog +All changes are logged in Changelog.md # Overview @@ -45,6 +43,29 @@ X's configuration: } ``` +# A note about directory structure +This project uses https://hub.docker.com/r/tiangolo/uwsgi-nginx-flask/ +This is a solution that runs a productionalized setup using NGINX+uwsgi+Flask (Flask is not meant to be run as a real webserver per their docs). This project requires the app/app structure. Tox still works from the root due to tox magic. + +# Running + +## Locally (no docker) +It is recommended that you do this step in a virtualenv. +(set -x is Fish notaion, change for Bash etc. accordingly) +``` +pip install --ignore-installed .; set -x CONSUL_HOST ; ./main.py +``` + +## Docker +## building +``` +docker build -t config_binding_service:myversion . +``` +## running +``` +docker run -dt -p myextport:80 config_binding_service:myversion +``` + # Testing You need tox: ``` diff --git a/app/app/MANIFEST.in b/app/app/MANIFEST.in new file mode 100644 index 0000000..3d5afa6 --- /dev/null +++ b/app/app/MANIFEST.in @@ -0,0 +1 @@ +include swagger.yaml diff --git a/app/app/__init__.py b/app/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/app/config_binding_service/__init__.py b/app/app/config_binding_service/__init__.py new file mode 100644 index 0000000..5a6b081 --- /dev/null +++ b/app/app/config_binding_service/__init__.py @@ -0,0 +1,40 @@ +# ============LICENSE_START======================================================= +# Copyright (c) 2017-2018 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 os + + +class BadEnviornmentENVNotFound(Exception): + """ + Specific exception to be raised when a required ENV varaible is missing + """ + pass + + +def get_consul_uri(): + """ + This method waterfalls reads an envioronmental variable called CONSUL_HOST + If that doesn't work, it raises an Exception + """ + if "CONSUL_HOST" in os.environ: + # WARNING! TODO! Currently the env file does not include the port. + # But some other people think that the port should be a part of that. + # For now, I'm hardcoding 8500 until this gets resolved. + return "http://{0}:{1}".format(os.environ["CONSUL_HOST"], 8500) + else: + raise BadEnviornmentENVNotFound("CONSUL_HOST") diff --git a/app/app/config_binding_service/client.py b/app/app/config_binding_service/client.py new file mode 100644 index 0000000..a87c3bc --- /dev/null +++ b/app/app/config_binding_service/client.py @@ -0,0 +1,279 @@ +# ============LICENSE_START======================================================= +# Copyright (c) 2017-2018 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 re +from functools import partial, reduce +import base64 +import copy +import json +import requests +import six +from config_binding_service import get_consul_uri +from config_binding_service.logging import LOGGER + + +CONSUL = get_consul_uri() + +template_match_rels = re.compile("\{{2}([^\}\{]*)\}{2}") +template_match_dmaap = re.compile("<{2}([^><]*)>{2}") + +### +# Cusom Exception +### + + +class CantGetConfig(Exception): + """ + Represents an exception where a required key in consul isn't there + """ + + def __init__(self, code, response): + self.code = code + self.response = response + + +class BadRequest(Exception): + """ + Exception to be raised when the user tried to do something they shouldn't + """ + + def __init__(self, response): + self.code = 400 + self.response = response + + +### +# Private Functions +### + + +def _consul_get_all_as_transaction(service_component_name): + """ + Use Consul's transaction API to get all keys of the form service_component_name:* + Return a dict with all the values decoded + """ + payload = [ + { + "KV": { + "Verb": "get-tree", + "Key": service_component_name, + } + }] + + response = requests.put("{0}/v1/txn".format(CONSUL), json=payload) + + try: + response.raise_for_status() + except requests.exceptions.HTTPError as exc: + raise CantGetConfig(exc.response.status_code, exc.response.text) + + result = json.loads(response.text)['Results'] + + new_res = {} + for res in result: + key = res["KV"]["Key"] + val = base64.b64decode(res["KV"]["Value"]).decode("utf-8") + try: + new_res[key] = json.loads(val) + except json.decoder.JSONDecodeError: + new_res[key] = "INVALID JSON" # TODO, should we just include the original value somehow? + + if service_component_name not in new_res: + raise CantGetConfig(404, "") + + return new_res + + +def _get_config_rels_dmaap(service_component_name): + allk = _consul_get_all_as_transaction(service_component_name) + config = allk[service_component_name] + rels = allk.get(service_component_name + ":rels", []) + dmaap = allk.get(service_component_name + ":dmaap", {}) + return config, rels, dmaap + + +def _get_connection_info_from_consul(service_component_name): + """ + Call consul's catalog + TODO: currently assumes there is only one service + + TODO: WARNING: FIXTHIS: CALLINTHENATIONALARMY: + This tries to determine that a service_component_name is a cdap application by inspecting service_component_name and name munging. However, this would force all CDAP applications to have cdap_app in their name. A much better way to do this is to do some kind of catalog_lookup here, OR MAYBE change this API so that the component_type is passed in somehow. THis is a gaping TODO. + """ + LOGGER.info("Retrieving connection information for %s", service_component_name) + res = requests.get( + "{0}/v1/catalog/service/{1}".format(CONSUL, service_component_name)) + res.raise_for_status() + services = res.json() + if services == []: + LOGGER.info("Warning: config and rels keys were both valid, but there is no component named %s registered in Consul!", service_component_name) + return None # later will get filtered out + ip_addr = services[0]["ServiceAddress"] + port = services[0]["ServicePort"] + if "cdap_app" in service_component_name: + redirectish_url = "http://{0}:{1}/application/{2}".format( + ip_addr, port, service_component_name) + LOGGER.info("component is a CDAP application; trying the broker redirect on %s", redirectish_url) + res = requests.get(redirectish_url) + res.raise_for_status() + details = res.json() + # Pick out the details to expose to the component developers. These keys come from the broker API + return {key: details[key] for key in ["connectionurl", "serviceendpoints"]} + return "{0}:{1}".format(ip_addr, port) + + +def _replace_rels_template(rels, template_identifier): + """ + The magic. Replaces a template identifier {{...}} with the entrie(s) from the rels keys + NOTE: There was a discussion over whether the CBS should treat {{}} as invalid. Mike asked that + it resolve to the empty list. So, it does resolve it to empty list. + """ + returnl = [] + for rel in rels: + if template_identifier in rel and template_identifier is not "": + returnl.append(rel) + # returnl now contains a list of DNS names (possible empty), now resolve them (or not if they are not regustered) + return list(filter(lambda x: x is not None, map(_get_connection_info_from_consul, returnl))) + + +def _replace_dmaap_template(dmaap, template_identifier): + """ + This one liner could have been just put inline in the caller but maybe this will get more complex in future + Talked to Mike, default value if key is not found in dmaap key should be {} + """ + return {} if (template_identifier not in dmaap or template_identifier == "<<>>") else dmaap[template_identifier] + + +def _replace_value(v, rels, dmaap): + """ + Takes a value v that was some value in the templatized configuration, determines whether it needs replacement (either {{}} or <<>>), and if so, replaces it. + Otherwise just returns v + + implementation notes: + - the split below sees if we have v = x,y,z... so we can support {{x,y,z,....}} + - the lambda is because we can't fold operators in Python, wanted fold(+, L) where + when applied to lists in python is list concatenation + """ + if isinstance(v, six.string_types): # do not try to replace anything that is not a string + match_on_rels = re.match(template_match_rels, v) + if match_on_rels: + # now holds just x,.. of {{x,...}} + template_identifier = match_on_rels.groups()[0].strip() + rtpartial = partial(_replace_rels_template, rels) + return reduce(lambda a, b: a + b, map(rtpartial, template_identifier.split(",")), []) + match_on_dmaap = re.match(template_match_dmaap, v) + if match_on_dmaap: + template_identifier = match_on_dmaap.groups()[0].strip() + """ + Here is what Mike said: + 1) want simple replacement of "<< >>" with dmaap key value + 2) never need to support <> whereas we do support {{sct1,sct2}} + The consequence is that if you give the CBS a dmaap key like {"foo" : {...}} you are going to get back {...}, but rels always returns [...]. + So now component developers have to possible handle dicts and [], and we have to communicate that to them + """ + return _replace_dmaap_template(dmaap, template_identifier) + return v # was not a match or was not a string, return value as is + + +def _recurse(config, rels, dmaap): + """ + Recurse throug a configuration, or recursively a sub elemebt of it. + If it's a dict: recurse over all the values + If it's a list: recurse over all the values + If it's a string: return the replacement + If none of the above, just return the item. + """ + if isinstance(config, list): + return [_recurse(item, rels, dmaap) for item in config] + if isinstance(config, dict): + for key in config: + config[key] = _recurse(config[key], rels, dmaap) + return config + if isinstance(config, six.string_types): + return _replace_value(config, rels, dmaap) + # not a dict, not a list, not a string, nothing to do. + return config + + +######### +# PUBLIC API +######### + + +def resolve(service_component_name): + """ + Return the bound config of service_component_name + """ + config, rels, dmaap = _get_config_rels_dmaap(service_component_name) + return _recurse(config, rels, dmaap) + + +def resolve_override(config, rels=[], dmaap={}): + """ + Explicitly take in a config, rels, dmaap and try to resolve it. + Useful for testing where you dont want to put the test values in consul + """ + # use deepcopy to make sure that config is not touched + return _recurse(copy.deepcopy(config), rels, dmaap) + + +def resolve_all(service_component_name): + """ + Return config, policies, and any other k such that service_component_name:k exists (other than :dmaap and :rels) + """ + allk = _consul_get_all_as_transaction(service_component_name) + returnk = {} + + # replace the config with the resolved config + returnk["config"] = resolve(service_component_name) + + # concatenate the items + for k in allk: + if "policies" in k: + if "policies" not in returnk: + returnk["policies"] = {} + returnk["policies"]["event"] = {} + returnk["policies"]["items"] = [] + + if k.endswith(":policies/event"): + returnk["policies"]["event"] = allk[k] + elif ":policies/items" in k: + returnk["policies"]["items"].append(allk[k]) + else: + if not(k == service_component_name or k.endswith(":rels") or k.endswith(":dmaap")): + # this would blow up if you had a key in consul without a : but this shouldnt happen + suffix = k.split(":")[1] + returnk[suffix] = allk[k] + + return returnk + + +def get_key(key, service_component_name): + """ + Try to fetch a key k from Consul of the form service_component_name:k + """ + if key == "policies": + raise BadRequest( + ":policies is a complex folder and should be retrieved using the service_component_all API") + response = requests.get( + "{0}/v1/kv/{1}:{2}".format(CONSUL, service_component_name, key)) + try: + response.raise_for_status() + except requests.exceptions.HTTPError as exc: + raise CantGetConfig(exc.response.status_code, exc.response.text) + rest = json.loads(response.text)[0] + return json.loads(base64.b64decode(rest["Value"]).decode("utf-8")) diff --git a/app/app/config_binding_service/controller.py b/app/app/config_binding_service/controller.py new file mode 100644 index 0000000..ec1fb86 --- /dev/null +++ b/app/app/config_binding_service/controller.py @@ -0,0 +1,105 @@ +# ============LICENSE_START======================================================= +# Copyright (c) 2017-2018 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 +import requests +import connexion +import uuid +from flask import Response +from config_binding_service import client, get_consul_uri +from config_binding_service.logging import LOGGER, audit, utc + + +def _get_helper(json_expecting_func, **kwargs): + """ + Helper function used by several functions below + """ + try: + payload = json_expecting_func(**kwargs) + response, status_code, mimetype = json.dumps(payload), 200, "application/json" + except client.BadRequest as exc: + response, status_code, mimetype = exc.response, exc.code, "text/plain" + except client.CantGetConfig as exc: + response, status_code, mimetype = exc.response, exc.code, "text/plain" + except Exception as exc: + LOGGER.error(exc) + response, status_code, mimetype = "Unknown error, please report", 500, "text/plain" + return response, status_code, mimetype + + +def _get_or_generate_xer(raw_request): + """get or generate the transaction id""" + rid = raw_request.headers.get("x-onap-requestid", None) + if rid is None: + # some components are still using the old name + rid = raw_request.headers.get("x-ecomp-requestid", None) + if rid is None: + # the user did NOT supply a request id, generate one + rid = str(uuid.uuid4()) + return rid + + +def bind_all(service_component_name): + """ + Get all the keys in Consul for this SCN, and bind the config + """ + rid = _get_or_generate_xer(connexion.request) + bts = utc() + response, status_code, mimetype = _get_helper(client.resolve_all, service_component_name=service_component_name) + audit(connexion.request, bts, rid, status_code, __name__) + # Even though some older components might be using the ecomp name, we return the proper one + return Response(response=response, status=status_code, mimetype=mimetype, headers={"x-onap-requestid": rid}) + + +def bind_config_for_scn(service_component_name): + """ + Bind just the config for this SCN + """ + print(connexion) + print(connexion.request) + rid = _get_or_generate_xer(connexion.request) + bts = utc() + response, status_code, mimetype = _get_helper(client.resolve, service_component_name=service_component_name) + audit(connexion.request, bts, rid, status_code, __name__) + return Response(response=response, status=status_code, mimetype=mimetype, headers={"x-onap-requestid": rid}) + + +def get_key(key, service_component_name): + """ + Get a single key k of the form service_component_name:k from Consul. + Should not be used and will return a BAD REQUEST for k=policies because it's a complex object + """ + rid = _get_or_generate_xer(connexion.request) + bts = utc() + response, status_code, mimetype = _get_helper(client.get_key, key=key, service_component_name=service_component_name) + audit(connexion.request, bts, rid, status_code, __name__) + return Response(response=response, status=status_code, mimetype=mimetype, headers={"x-onap-requestid": rid}) + + +def healthcheck(): + """ + CBS Healthcheck + """ + LOGGER.info("healthcheck called") + res = requests.get( + "{0}/v1/catalog/service/config_binding_service".format(get_consul_uri())) + if res.status_code == 200: + return Response(response="CBS is alive and Consul connection OK", + status=200) + return Response(response="CBS is alive but cannot reach Consul", + status=503) diff --git a/app/app/config_binding_service/logging.py b/app/app/config_binding_service/logging.py new file mode 100644 index 0000000..f5cd6af --- /dev/null +++ b/app/app/config_binding_service/logging.py @@ -0,0 +1,94 @@ +# ============LICENSE_START======================================================= +# Copyright (c) 2017-2018 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 logging import getLogger, StreamHandler, Formatter +from logging.handlers import RotatingFileHandler +from os import makedirs +import datetime + + +LOGGER = getLogger("defaultlogger") + + +def _create_logger(name, logfile): + """ + Create a RotatingFileHandler and a streamhandler for stdout + https://docs.python.org/3/library/logging.handlers.html + what's with the non-pythonic naming in these stdlib methods? Shameful. + """ + logger = getLogger(name) + file_handler = RotatingFileHandler(logfile, + maxBytes=10000000, backupCount=2) # 10 meg with one backup.. + formatter = Formatter('%(message)s') + file_handler.setFormatter(formatter) + stream_handler = StreamHandler() + stream_handler.setFormatter(formatter) + logger.setLevel("DEBUG") + logger.addHandler(file_handler) + logger.addHandler(stream_handler) + return logger + + +def create_logger(): + """ + Public method to set the global logger, launched from Run + """ + LOGFILE = "/opt/logs/log.log" + makedirs("/opt/logs", exist_ok=True) + open(LOGFILE, 'a').close() # this is like "touch" + global LOGGER + LOGGER = _create_logger("config_binding_service", LOGFILE) + + +def utc(): + """gets current time in utc""" + return datetime.datetime.utcnow() + + +def audit(raw_request, bts, xer, rcode, calling_mod, msg="n/a"): + """ + write an EELF audit record per https://wiki.onap.org/download/attachments/1015849/ONAP%20application%20logging%20guidelines.pdf?api=v2 + %The audit fields implemented: + + 1 BeginTimestamp Implemented (bts) + 2 EndTimestamp Auto Injected when this is called + 3 RequestID Implemented (xer) + 7 serviceName Implemented (from Req) + 9 StatusCode Auto injected based on rcode + 10 ResponseCode Implemented (rcode) + 15 Server IP address Implemented (from Req) + 16 ElapsedTime Auto Injected (milliseconds) + 18 ClientIPaddress Implemented (from Req) + 19 class name Implemented (mod), though docs say OOP, I am using the python module here + 20 Unused ...implemented.... + 21-25 Custom n/a + 26 detailMessage Implemented (msg) + """ + ets = utc() + + LOGGER.info("{bts}|{ets}|{xer}||||{path}||{status}|{rcode}|||||{servip}|{et}||{clientip}|{calling_mod}|||||||{msg}".format( + bts=bts.isoformat(), + ets=ets.isoformat(), + xer=xer, rcode=rcode, + path=raw_request.path.split("/")[1], + status="COMPLETE" if rcode == 200 else "ERROR", + servip=raw_request.host.split(":")[0], + et=int((ets - bts).microseconds / 1000), # supposed to be in milleseconds + clientip=raw_request.remote_addr, + calling_mod=calling_mod, msg=msg + )) diff --git a/app/app/main.py b/app/app/main.py new file mode 100755 index 0000000..6cedc4d --- /dev/null +++ b/app/app/main.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 + +# ============LICENSE_START======================================================= +# Copyright (c) 2017-2018 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 connexion +from config_binding_service.logging import create_logger + +# Entrypoint When in uwsgi +# This create logger call used to be in the main block, but when moving to NGINX+uwsgi, this had to change. See https://hub.docker.com/r/tiangolo/uwsgi-nginx-flask/ +create_logger() +app = connexion.App(__name__, specification_dir='.') +app.add_api('swagger.yaml', arguments={'title': 'Config Binding Service'}) + +if __name__ == "__main__": + # Only for debugging while developing + app.run(host='0.0.0.0', port=10000, debug=True) diff --git a/app/app/setup.py b/app/app/setup.py new file mode 100644 index 0000000..df59069 --- /dev/null +++ b/app/app/setup.py @@ -0,0 +1,31 @@ +# ============LICENSE_START======================================================= +# Copyright (c) 2017-2018 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 + +setup( + name='config_binding_service', + version='2.2.0', + packages=find_packages(exclude=["tests.*", "tests"]), + author="Tommy Carpenter", + author_email="tommy@research.att.com", + description='Service to fetch and bind configurations', + url="https://gerrit.onap.org/r/#/admin/projects/dcaegen2/platform/configbinding", + install_requires=["requests", "Flask", "connexion", "six"], + include_package_data=True +) diff --git a/app/app/swagger.yaml b/app/app/swagger.yaml new file mode 100644 index 0000000..cfe0944 --- /dev/null +++ b/app/app/swagger.yaml @@ -0,0 +1,100 @@ +# ============LICENSE_START======================================================= +# Copyright (c) 2017-2018 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. + + +--- +swagger: "2.0" +info: + version: "2.1.0" + title: "Config Binding Service" +paths: + /service_component/{service_component_name}: + parameters: + - name: "service_component_name" + in: "path" + description: "Service Component Name. service_component_name must be a key in consul." + required: true + type: "string" + get: + description: "Binds the configuration for service_component_name and returns the bound configuration as a JSON" + operationId: "config_binding_service.controller.bind_config_for_scn" + responses: + 200: + description: OK; the bound config is returned as an object + schema: + type: object + 404: + description: there is no configuration in Consul for this component + + /service_component_all/{service_component_name}: + parameters: + - name: "service_component_name" + in: "path" + description: "Service Component Name. service_component_name must be a key in consul." + required: true + type: "string" + get: + description: "Binds the configuration for service_component_name and returns the bound configuration, policies, and any other keys that are in Consul" + operationId: "config_binding_service.controller.bind_all" + responses: + 200: + description: "OK; returns {config : ..., policies : ....., k : ...} for all other k in Consul" + schema: + type: object + 404: + description: there is no configuration in Consul for this component + + /{key}/{service_component_name}: + parameters: + - name: "key" + in: "path" + description: "this endpoint tries to pull service_component_name:key; key is the key after the colon" + required: true + type: "string" + - name: "service_component_name" + in: "path" + description: "Service Component Name." + required: true + type: "string" + get: + description: "this is an endpoint that fetches a generic service_component_name:key out of Consul. The idea is that we don't want to tie components to Consul directly in case we swap out the backend some day, so the CBS abstracts Consul from clients. The structuring and weird collision of this new API with the above is unfortunate but due to legacy concerns." + operationId: "config_binding_service.controller.get_key" + responses: + 200: + description: "OK; returns service_component_name:key" + schema: + type: object + 404: + description: "key does not exist" + schema: + type: string + 400: + description: "bad request. Currently this is only returned on :policies, which is a complex object, and should be gotten through service_component_all" + schema: + type: string + + /healthcheck: + get: + description: "This is the health check endpoint. If this returns a 200, the server is alive and consul can be reached. If not a 200, either dead, or no connection to consul" + operationId: "config_binding_service.controller.healthcheck" + parameters: [] + responses: + 200: + description: Successful response + 503: + description: the config binding service cannot reach Consul diff --git a/app/app/tests/__init__.py b/app/app/tests/__init__.py new file mode 100644 index 0000000..1875bf6 --- /dev/null +++ b/app/app/tests/__init__.py @@ -0,0 +1,21 @@ +# ================================================================================ +# Copyright (c) 2018 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. + + +# empty __init__.py so that pytest can add correct path to coverage report, -- per pytest +# best practice guideline diff --git a/app/app/tests/conftest.py b/app/app/tests/conftest.py new file mode 100644 index 0000000..c8f2a06 --- /dev/null +++ b/app/app/tests/conftest.py @@ -0,0 +1,70 @@ +import pytest +from requests.exceptions import HTTPError +from config_binding_service import get_consul_uri + + +class FakeResponse(): + def __init__(self, status_code, text): + self.text = text + self.status_code = status_code + + def raise_for_status(self): + if self.status_code >= 400: + raise HTTPError(response=FakeResponse(404, "")) + + +@pytest.fixture +def expected_config(): + return {"deep": {"ALL YOUR SERVICE BELONG TO US": ['6.6.6.6:666', '7.7.7.7:777', '5.5.5.5:555', '5.5.5.5:555']}, + "doubledeep": {"sodeep": {"hello": "darkness"}}} + + +@pytest.fixture +def monkeyed_get_connection_info_from_consul(): + def _monkeyed_get_connection_info_from_consul(service_component_name): + # shared monkeypatch. probably somewhat lazy because the function htis patches can be broken up. + if service_component_name == "cdap": + return '666.666.666.666:666' + elif service_component_name == "testing_bravo.somedomain.com": + return '7.7.7.7:777' + elif service_component_name == "testing_alpha.somedomain.com": + return '6.6.6.6:666' + elif service_component_name == "testing_charlie.somedomain.com": + return '5.5.5.5:555' + elif service_component_name == "nonexistent_hope": + return None # the real function returns None here + elif service_component_name == "cdap_serv.dcae.ecomp.somedomain.com": + broker_ip = '1.1.1.1' + broker_port = 444 + return "http://{0}:{1}/application/{2}".format(broker_ip, broker_port, service_component_name) + return _monkeyed_get_connection_info_from_consul + + +@pytest.fixture +def monkeyed_requests_get(): + def _monkeyed_requests_get(url): + if url == "{0}/v1/kv/test_service_component_name.unknown.unknown.unknown.dcae.onap.org:dti".format(get_consul_uri()): + return FakeResponse(status_code=200, text='[{"LockIndex":0,"Key":"test_service_component_name.unknown.unknown.unknown.dcae.onap.org:dti","Flags":0,"Value": "eyJteSIgOiAiZHRpIn0=","CreateIndex":4066524,"ModifyIndex":4066524}]') + else: + return FakeResponse(status_code=404, text="") + return _monkeyed_requests_get + + +@pytest.fixture +def monkeyed_requests_put(): + def _monkeyed_requests_put(url, json): + if url == "{0}/v1/txn".format(get_consul_uri()): + key = json[0]["KV"]["Key"] + if key == "test_service_component_name.unknown.unknown.unknown.dcae.onap.org": + return FakeResponse(status_code=200, text='{"Results":[{"KV":{"LockIndex":0,"Key":"test_service_component_name.unknown.unknown.unknown.dcae.onap.org","Flags":0,"Value":"eyJteSIgOiAiYW1hemluZyBjb25maWcifQ==","CreateIndex":4051555,"ModifyIndex":4051555}},{"KV":{"LockIndex":0,"Key":"test_service_component_name.unknown.unknown.unknown.dcae.onap.org:dmaap","Flags":0,"Value":"eyJmb28iIDogImJhciJ9","CreateIndex":4051571,"ModifyIndex":4051571}},{"KV":{"LockIndex":0,"Key":"test_service_component_name.unknown.unknown.unknown.dcae.onap.org:dti","Flags":0,"Value":"eyJteSIgOiAiZHRpIn0=","CreateIndex":4066524,"ModifyIndex":4066524}},{"KV":{"LockIndex":0,"Key":"test_service_component_name.unknown.unknown.unknown.dcae.onap.org:policies/event","Flags":0,"Value":"eyJhY3Rpb24iOiAiZ2F0aGVyZWQiLCAidGltZXN0YW1wIjogIjIwMTgtMDItMTkgMTU6MzY6NDQuODc3MzgwIiwgInVwZGF0ZV9pZCI6ICJiYjczYzIwYS01ZmY4LTQ1MGYtODIyMy1kYTY3MjBhZGUyNjciLCAicG9saWNpZXNfY291bnQiOiAyfQ==","CreateIndex":4048564,"ModifyIndex":4048564}},{"KV":{"LockIndex":0,"Key":"test_service_component_name.unknown.unknown.unknown.dcae.onap.org:policies/items/DCAE_alex.Config_MS_alex_microservice","Flags":0,"Value":"eyJwb2xpY3lOYW1lIjogIkRDQUVfYWxleC5Db25maWdfTVNfYWxleF9taWNyb3NlcnZpY2UuMTMyLnhtbCIsICJwb2xpY3lDb25maWdNZXNzYWdlIjogIkNvbmZpZyBSZXRyaWV2ZWQhICIsICJyZXNwb25zZUF0dHJpYnV0ZXMiOiB7fSwgInBvbGljeUNvbmZpZ1N0YXR1cyI6ICJDT05GSUdfUkVUUklFVkVEIiwgIm1hdGNoaW5nQ29uZGl0aW9ucyI6IHsiT05BUE5hbWUiOiAiRENBRSIsICJOYW1lIjogIkRDQUUiLCAiQ29uZmlnTmFtZSI6ICJhbGV4X2NvbmZpZ19uYW1lIn0sICJjb25maWciOiB7InBvbGljeVNjb3BlIjogImFsZXhfcG9saWN5X3Njb3BlIiwgImNvbmZpZ05hbWUiOiAiYWxleF9jb25maWdfbmFtZSIsICJkZXNjcmlwdGlvbiI6ICJ0ZXN0IERDQUUgcG9saWN5LWhhbmRsZXIiLCAic2VydmljZSI6ICJhbGV4X3NlcnZpY2UiLCAicG9saWN5TmFtZSI6ICJhbGV4X3BvbGljeV9uYW1lIiwgInJpc2tMZXZlbCI6ICIzIiwgImtleTEiOiAidmFsdWUxIiwgInBvbGljeV9oZWxsbyI6ICJ3b3JsZCEiLCAiY29udGVudCI6IHsiZm9vIjogIm1pY3Jvc2VydmljZTMiLCAiZm9vX3VwZGF0ZWQiOiAiMjAxOC0wMS0zMFQxMzoyNTozMy4yMjJaIn0sICJyaXNrVHlwZSI6ICIxNzEyX0VURSIsICJndWFyZCI6ICJGYWxzZSIsICJ2ZXJzaW9uIjogIjAuMC4xIiwgImxvY2F0aW9uIjogIkNlbnRyYWwiLCAicG9saWN5X3VwZGF0ZWRfdHMiOiAiMjAxOC0wMi0xOVQxNTowOTo1NS4yMTdaIiwgInVwZGF0ZWRfcG9saWN5X2lkIjogIkRDQUVfYWxleC5Db25maWdfTVNfYWxleF9taWNyb3NlcnZpY2UiLCAicG9saWN5X3VwZGF0ZWRfdG9fdmVyIjogIjEzMiIsICJwcmlvcml0eSI6ICI0IiwgInBvbGljeV91cGRhdGVkX2Zyb21fdmVyIjogIjEzMSIsICJ0ZW1wbGF0ZVZlcnNpb24iOiAiMiIsICJ1dWlkIjogIjVlODdkN2M1LTBkYWYtNGI2Yi1hYjkyLTUzNjVjZjVkYjFlZiJ9LCAicHJvcGVydHkiOiBudWxsLCAidHlwZSI6ICJKU09OIiwgInBvbGljeVZlcnNpb24iOiAiMTMyIn0=","CreateIndex":4048564,"ModifyIndex":4065574}},{"KV":{"LockIndex":0,"Key":"test_service_component_name.unknown.unknown.unknown.dcae.onap.org:policies/items/DCAE_alex.Config_db_client_policy_id_value","Flags":0,"Value":"eyJwb2xpY3lOYW1lIjogIkRDQUVfYWxleC5Db25maWdfZGJfY2xpZW50X3BvbGljeV9pZF92YWx1ZS4xMzMueG1sIiwgInBvbGljeUNvbmZpZ01lc3NhZ2UiOiAiQ29uZmlnIFJldHJpZXZlZCEgIiwgInJlc3BvbnNlQXR0cmlidXRlcyI6IHt9LCAicG9saWN5Q29uZmlnU3RhdHVzIjogIkNPTkZJR19SRVRSSUVWRUQiLCAibWF0Y2hpbmdDb25kaXRpb25zIjogeyJPTkFQTmFtZSI6ICJEQ0FFIiwgIk5hbWUiOiAiRENBRSIsICJDb25maWdOYW1lIjogImFsZXhfY29uZmlnX25hbWUifSwgImNvbmZpZyI6IHsiZGJfY2xpZW50X3RzIjogIjIwMTctMTEtMjFUMTI6MTI6MTMuNjk2WiIsICJkYl9jbGllbnQiOiAiaXBzdW0iLCAicG9saWN5X2hlbGxvIjogIndvcmxkISIsICJwb2xpY3lfdXBkYXRlZF9mcm9tX3ZlciI6ICIxMzIiLCAidXBkYXRlZF9wb2xpY3lfaWQiOiAiRENBRV9hbGV4LkNvbmZpZ19kYl9jbGllbnRfcG9saWN5X2lkX3ZhbHVlIiwgInBvbGljeV91cGRhdGVkX3RzIjogIjIwMTgtMDItMTlUMTU6MDk6NTUuODEyWiIsICJwb2xpY3lfdXBkYXRlZF90b192ZXIiOiAiMTMzIn0sICJwcm9wZXJ0eSI6IG51bGwsICJ0eXBlIjogIkpTT04iLCAicG9saWN5VmVyc2lvbiI6ICIxMzMifQ==","CreateIndex":4048564,"ModifyIndex":4065570}},{"KV":{"LockIndex":0,"Key":"test_service_component_name.unknown.unknown.unknown.dcae.onap.org:rels","Flags":0,"Value":"WyJteS5hbWF6aW5nLnJlbGF0aW9uc2hpcCJd","CreateIndex":4051567,"ModifyIndex":4051567}}],"Errors":null,"Index":0,"LastContact":0,"KnownLeader":true}') + elif key == "scn_exists": + return FakeResponse(status_code=200, text='{"Results":[{"KV":{"LockIndex":0,"Key":"scn_exists","Flags":0,"Value":"eyJmb28zIiA6ICJiYXIzIn0=","CreateIndex":4067403,"ModifyIndex":4067403}},{"KV":{"LockIndex":0,"Key":"scn_exists:dmaap","Flags":0,"Value":"eyJmb280IiA6ICJiYXI0In0=","CreateIndex":4067410,"ModifyIndex":4067410}},{"KV":{"LockIndex":0,"Key":"scn_exists:rels","Flags":0,"Value":"WyJmb28iXQ==","CreateIndex":4067406,"ModifyIndex":4067406}},{"KV":{"LockIndex":0,"Key":"scn_exists_nord","Flags":0,"Value":"eyJmb281IiA6ICJiYXI1In0=","CreateIndex":4067340,"ModifyIndex":4067340}}],"Errors":null,"Index":0,"LastContact":0,"KnownLeader":true}') + elif key == "scn_exists_nord": + return FakeResponse(status_code=200, text='{"Results":[{"KV":{"LockIndex":0,"Key":"scn_exists_nord","Flags":0,"Value":"eyJmb281IiA6ICJiYXI1In0=","CreateIndex":4067340,"ModifyIndex":4067340}}],"Errors":null,"Index":0,"LastContact":0,"KnownLeader":true}') + elif key == "test_resolve_scn": + return FakeResponse(status_code=200, text='{"Results":[{"KV":{"LockIndex":0,"Key":"test_resolve_scn","Flags":0,"Value":"ewogICAgICAgICAgICAgICAgImRlZXAiIDogewogICAgICAgICAgICAgICAgICAgICJBTEwgWU9VUiBTRVJWSUNFIEJFTE9ORyBUTyBVUyIgOiAie3thbHBoYSxicmF2byxjaGFybGllfX0ifSwKICAgICAgICAgICAgICAgICJkb3VibGVkZWVwIiA6ICB7CiAgICAgICAgICAgICAgICAgICAgInNvZGVlcCIgOiB7ImhlbGxvIiA6ICI8PFdITz8+PiJ9fQogICAgICAgICAgICAgfQo=","CreateIndex":4068002,"ModifyIndex":4068002}},{"KV":{"LockIndex":0,"Key":"test_resolve_scn:dmaap","Flags":0,"Value":"eyJXSE8/IiA6ICJkYXJrbmVzcyJ9","CreateIndex":4068013,"ModifyIndex":4068013}},{"KV":{"LockIndex":0,"Key":"test_resolve_scn:rels","Flags":0,"Value":"WyJ0ZXN0aW5nX2FscGhhLnNvbWVkb21haW4uY29tIiwgInRlc3RpbmdfYnJhdm8uc29tZWRvbWFpbi5jb20iLCAidGVzdGluZ19jaGFybGllLnNvbWVkb21haW4uY29tIiwgInRlc3RpbmdfY2hhcmxpZS5zb21lZG9tYWluLmNvbSJd","CreateIndex":4068010,"ModifyIndex":4068010}}],"Errors":null,"Index":0,"LastContact":0,"KnownLeader":true}') + elif key == "cbs_test_messed_up": + return FakeResponse(status_code=200, text='{"Results":[{"KV":{"LockIndex":0,"Key":"cbs_test_messed_up","Flags":0,"Value":"eyJmb28iIDogImJhciJ9","CreateIndex":4864032,"ModifyIndex":4864052}},{"KV":{"LockIndex":0,"Key":"cbs_test_messed_up:badkey","Flags":0,"Value":"eyJub3QgYSBqc29ubm5uIFJFS1RUVFQ=","CreateIndex":4864075,"ModifyIndex":4864075}}],"Errors":null,"Index":0,"LastContact":0,"KnownLeader":true}') + elif key == "scn_NOTexists": + return FakeResponse(status_code=404, text="") + return _monkeyed_requests_put diff --git a/app/app/tests/test_client.py b/app/app/tests/test_client.py new file mode 100644 index 0000000..06a3dc2 --- /dev/null +++ b/app/app/tests/test_client.py @@ -0,0 +1,159 @@ +from config_binding_service import client + + +def test_consul_get_all_as_transaction(monkeypatch, monkeyed_requests_put): + """tests _consul_get_all_as_transaction""" + monkeypatch.setattr('requests.put', monkeyed_requests_put) + allk = client._consul_get_all_as_transaction( + "test_service_component_name.unknown.unknown.unknown.dcae.onap.org") + assert allk == { + 'test_service_component_name.unknown.unknown.unknown.dcae.onap.org': {'my': 'amazing config'}, + 'test_service_component_name.unknown.unknown.unknown.dcae.onap.org:dti': {'my': 'dti'}, + 'test_service_component_name.unknown.unknown.unknown.dcae.onap.org:dmaap': {'foo': 'bar'}, + 'test_service_component_name.unknown.unknown.unknown.dcae.onap.org:policies/event': {'action': 'gathered', 'timestamp': '2018-02-19 15:36:44.877380', 'update_id': 'bb73c20a-5ff8-450f-8223-da6720ade267', 'policies_count': 2}, + 'test_service_component_name.unknown.unknown.unknown.dcae.onap.org:policies/items/DCAE_alex.Config_MS_alex_microservice': {'policyName': 'DCAE_alex.Config_MS_alex_microservice.132.xml', 'policyConfigMessage': 'Config Retrieved! ', 'responseAttributes': {}, 'policyConfigStatus': 'CONFIG_RETRIEVED', 'matchingConditions': {'ONAPName': 'DCAE', 'Name': 'DCAE', 'ConfigName': 'alex_config_name'}, 'config': {'policyScope': 'alex_policy_scope', 'configName': 'alex_config_name', 'description': 'test DCAE policy-handler', 'service': 'alex_service', 'policyName': 'alex_policy_name', 'riskLevel': '3', 'key1': 'value1', 'policy_hello': 'world!', 'content': {'foo': 'microservice3', 'foo_updated': '2018-01-30T13:25:33.222Z'}, 'riskType': '1712_ETE', 'guard': 'False', 'version': '0.0.1', 'location': 'Central', 'policy_updated_ts': '2018-02-19T15:09:55.217Z', 'updated_policy_id': 'DCAE_alex.Config_MS_alex_microservice', 'policy_updated_to_ver': '132', 'priority': '4', 'policy_updated_from_ver': '131', 'templateVersion': '2', 'uuid': '5e87d7c5-0daf-4b6b-ab92-5365cf5db1ef'}, 'property': None, 'type': 'JSON', 'policyVersion': '132'}, + 'test_service_component_name.unknown.unknown.unknown.dcae.onap.org:policies/items/DCAE_alex.Config_db_client_policy_id_value': {'policyName': 'DCAE_alex.Config_db_client_policy_id_value.133.xml', 'policyConfigMessage': 'Config Retrieved! ', 'responseAttributes': {}, 'policyConfigStatus': 'CONFIG_RETRIEVED', 'matchingConditions': {'ONAPName': 'DCAE', 'Name': 'DCAE', 'ConfigName': 'alex_config_name'}, 'config': {'db_client_ts': '2017-11-21T12:12:13.696Z', 'db_client': 'ipsum', 'policy_hello': 'world!', 'policy_updated_from_ver': '132', 'updated_policy_id': 'DCAE_alex.Config_db_client_policy_id_value', 'policy_updated_ts': '2018-02-19T15:09:55.812Z', 'policy_updated_to_ver': '133'}, 'property': None, 'type': 'JSON', 'policyVersion': '133'}, + 'test_service_component_name.unknown.unknown.unknown.dcae.onap.org:rels': ['my.amazing.relationship'] + } + + allk = client._consul_get_all_as_transaction("cbs_test_messed_up") + assert allk == {'cbs_test_messed_up': {'foo': 'bar'}, + 'cbs_test_messed_up:badkey': 'INVALID JSON'} + + +def test_get_config_rels_dmaap(monkeypatch, monkeyed_requests_put): + monkeypatch.setattr('requests.put', monkeyed_requests_put) + assert ({"foo3": "bar3"}, ["foo"], {"foo4": "bar4"}) == client._get_config_rels_dmaap("scn_exists") + assert ({"foo5": "bar5"}, [], {}) == client._get_config_rels_dmaap("scn_exists_nord") + + +def test_bad_config_http(): + test_config = {'yeahhhhh': "{{}}"} + test_rels = ["testing_bravo.somedomain.com"] + assert {'yeahhhhh': []} == client.resolve_override(test_config, test_rels) + + +def test_bad_config_dmaap(): + test_config = {'darkness': "<<>>"} + test_dmaap = {"WHO?": "darkness"} + assert {'darkness': {}} == client.resolve_override(test_config, test_dmaap) + + +def test_config_with_list(monkeypatch, monkeyed_get_connection_info_from_consul): + monkeypatch.setattr('config_binding_service.client._get_connection_info_from_consul', + monkeyed_get_connection_info_from_consul) + test_config_1 = {"dcae_target_type": [ + "vhss-ems", "pcrf-oam"], "downstream-laika": "{{ laika }}", "some-param": "Lorem ipsum dolor sit amet"} + test_rels_1 = ["3df5292249ae4a949f173063617cea8d_docker-snmp-polling-firstnet-m"] + test_bind_1 = client.resolve_override(test_config_1, test_rels_1, {}) + assert(test_bind_1 == {'dcae_target_type': [ + 'vhss-ems', 'pcrf-oam'], 'downstream-laika': [], 'some-param': 'Lorem ipsum dolor sit amet'}) + + test_config_2 = {"foo": ["{{cdap}}", "notouching", "<>"]} + test_rels_2 = ["cdap"] + test_dmaap_2 = {"yo": "im here"} + test_bind_2 = client.resolve_override(test_config_2, test_rels_2, test_dmaap_2) + assert(test_bind_2 == {"foo": [['666.666.666.666:666'], "notouching", "im here"]}) + + +def test_cdap(monkeypatch, monkeyed_get_connection_info_from_consul): + # user override to test CDAP functionality + monkeypatch.setattr('config_binding_service.client._get_connection_info_from_consul', + monkeyed_get_connection_info_from_consul) + test_rels = ["testing_alpha.somedomain.com", "testing_bravo.somedomain.com", + "testing_charlie.somedomain.com", "testing_charlie.somedomain.com", "cdap"] + test_config = {"streams_publishes": "{{alpha}}", + # should be dumped + "services_calls": [{"somekey": "{{charlie}}"}], + "cdap_to_manage": {'some_nested_thing': "{{cdap}}"}} # no dumps + test_bind_1 = client.resolve_override(test_config, test_rels) + assert test_bind_1 == {'services_calls': [{"somekey": ["5.5.5.5:555", "5.5.5.5:555"]}], 'streams_publishes': [ + "6.6.6.6:666"], 'cdap_to_manage': {'some_nested_thing': ['666.666.666.666:666']}} + assert test_bind_1['services_calls'] == [{"somekey": ["5.5.5.5:555", "5.5.5.5:555"]}] + assert test_bind_1['streams_publishes'] == ["6.6.6.6:666"] + + +def test_multiple_service_types(monkeypatch, monkeyed_get_connection_info_from_consul): + # test {{x,y,z}} + monkeypatch.setattr('config_binding_service.client._get_connection_info_from_consul', + monkeyed_get_connection_info_from_consul) + + # test 1: they all resovle + test_rels = ["testing_alpha.somedomain.com", "testing_bravo.somedomain.com", + "testing_charlie.somedomain.com", "testing_charlie.somedomain.com"] + config = {"ALL YOUR SERVICE BELONG TO US": "{{alpha,bravo,charlie}}"} + test_bind_1 = client.resolve_override(config, test_rels) + assert(test_bind_1 == {"ALL YOUR SERVICE BELONG TO US": [ + '6.6.6.6:666', '7.7.7.7:777', '5.5.5.5:555', '5.5.5.5:555']}) + + # test 2: two resolve, one is missing from rels key + config2 = {"two there one not exist": "{{alpha,bravo,notexist}}"} + test_bind_2 = client.resolve_override(config2, test_rels) + assert(test_bind_2 == {"two there one not exist": [ + '6.6.6.6:666', '7.7.7.7:777']}) + + # test 3: two resolve, one is in rels key but not registered + config3 = {"two there one unregistered": "{{alpha,bravo,unregistered}}"} + test_rels3 = ["testing_alpha.somedomain.com", + "testing_bravo.somedomain.com", "unregistered.somedomain.com"] + test_bind_3 = client.resolve_override(config3, test_rels3) + assert(test_bind_3 == {"two there one unregistered": [ + '6.6.6.6:666', '7.7.7.7:777']}) + + +def test_dmaap(monkeypatch): + # test resolving dmaap key + config = {"TODAY IS YOUR LUCKY DAY": "<>"} + # does not match + test_bind = client.resolve_override( + config, dmaap={"XX": "ABSOLVEME"}) # XX != XXX + assert(test_bind == {"TODAY IS YOUR LUCKY DAY": {}}) + # matches + test_bind_2 = client.resolve_override(config, dmaap={"XXX": "ABSOLVEME"}) + assert(test_bind_2 == {"TODAY IS YOUR LUCKY DAY": "ABSOLVEME"}) + + +def test_config(monkeypatch, monkeyed_get_connection_info_from_consul): + # test config override + monkeypatch.setattr('config_binding_service.client._get_connection_info_from_consul', + monkeyed_get_connection_info_from_consul) + test_config = {"autoderegisterafter": "10m", "cdap_to_manage": { + 'some_nested_thing': "{{cdap}}"}, "bindingttw": 5, "hcinterval": "5s"} + test_rels = ["cdap"] + test_bind_1 = client.resolve_override(test_config, test_rels) + assert test_bind_1 == {'autoderegisterafter': '10m', 'cdap_to_manage': { + 'some_nested_thing': ['666.666.666.666:666']}, 'bindingttw': 5, 'hcinterval': '5s'} + + +def test_non_existent(monkeypatch, monkeyed_get_connection_info_from_consul): + # test a valid config-rels but the key is not in Consul + monkeypatch.setattr('config_binding_service.client._get_connection_info_from_consul', + monkeyed_get_connection_info_from_consul) + test_config = {"you shall not be fufilled": "{{nonexistent_hope}}"} + # hopefully not registered in Consul.. + test_rels = ["nonexistent_hope.rework-central.ecomp.somedomain.com"] + test_bind_1 = client.resolve_override(test_config, test_rels, {}) + assert(test_bind_1 == {"you shall not be fufilled": []}) + + +def test_broker_redirect(monkeypatch, monkeyed_get_connection_info_from_consul): + # test the broker redirect + monkeypatch.setattr('config_binding_service.client._get_connection_info_from_consul', + monkeyed_get_connection_info_from_consul) + test_config = {"gimmie_dat_cdap": "{{cdap_serv}}"} + test_rels = ["cdap_serv.dcae.ecomp.somedomain.com"] + assert {"gimmie_dat_cdap": ['http://1.1.1.1:444/application/cdap_serv.dcae.ecomp.somedomain.com'] + } == client.resolve_override(test_config, test_rels) + + +def test_both(monkeypatch, monkeyed_get_connection_info_from_consul, expected_config): + # test rels and http + monkeypatch.setattr('config_binding_service.client._get_connection_info_from_consul', + monkeyed_get_connection_info_from_consul) + test_rels = ["testing_alpha.somedomain.com", "testing_bravo.somedomain.com", + "testing_charlie.somedomain.com", "testing_charlie.somedomain.com"] + test_dmaap = {"WHO?": "darkness"} + config = {"deep": {"ALL YOUR SERVICE BELONG TO US": "{{alpha,bravo,charlie}}"}, + "doubledeep": {"sodeep": {"hello": "<>"}}} + test_bind_1 = client.resolve_override(config, test_rels, test_dmaap) + assert(test_bind_1 == expected_config) diff --git a/app/app/tests/test_controller.py b/app/app/tests/test_controller.py new file mode 100644 index 0000000..cda1fc7 --- /dev/null +++ b/app/app/tests/test_controller.py @@ -0,0 +1,113 @@ +# ============LICENSE_START======================================================= +# Copyright (c) 2017-2018 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 +import pytest +from config_binding_service import client, controller + + +# pytest doesnt support objects in conftest yet +class FakeConnexion(object): + def __init__(self, headers, path, host, remote_addr): + self.headers = headers + self.path = path + self.host = host + self.remote_addr = remote_addr + + +def test_bind_config_for_scn(monkeypatch, monkeyed_requests_put): + monkeypatch.setattr('requests.put', monkeyed_requests_put) + monkeypatch.setattr('connexion.request', FakeConnexion({"x-onap-requestid": 123456789}, "/service_component", "mytestingmachine", "myremoteclient")) + + assert(client.resolve("scn_exists") == {"foo3": "bar3"}) + with pytest.raises(client.CantGetConfig): + client.resolve("scn_NOTexists") + + R = controller.bind_config_for_scn("scn_exists") + assert(json.loads(R.data) == {"foo3": "bar3"}) + assert(R.status_code == 200) + assert(R.headers["x-onap-requestid"] == "123456789") + + R = controller.bind_config_for_scn("scn_NOTexists") + assert(R.status_code == 404) + assert(R.headers["x-onap-requestid"] == "123456789") + + R = controller.bind_config_for_scn("asdfasdf") + assert(R.status_code == 500) + assert(R.headers["x-onap-requestid"] == "123456789") + + +def test_generic(monkeypatch, monkeyed_requests_get, monkeyed_requests_put): + monkeypatch.setattr('requests.put', monkeyed_requests_put) + monkeypatch.setattr('requests.get', monkeyed_requests_get) + assert client.get_key("dti", "test_service_component_name.unknown.unknown.unknown.dcae.onap.org") == json.loads('{"my": "dti"}') + with pytest.raises(client.CantGetConfig): + client.get_key( + "nokeyforyou", "test_service_component_name.unknown.unknown.unknown.dcae.onap.org") + + monkeypatch.setattr('connexion.request', FakeConnexion({}, "/get_key", "mytestingmachine", "myremoteclient")) + + R = controller.get_key( + "dti", "test_service_component_name.unknown.unknown.unknown.dcae.onap.org") + assert(json.loads(R.data) == {"my": "dti"}) + assert(R.status_code == 200) + assert "x-onap-requestid" in R.headers + + R = controller.get_key( + "nokeyforyou", "test_service_component_name.unknown.unknown.unknown.dcae.onap.org") + assert(R.status_code == 404) + assert "x-onap-requestid" in R.headers + + R = controller.get_key( + "policies", "test_service_component_name.unknown.unknown.unknown.dcae.onap.org") + assert(R.status_code == 400) + assert "x-onap-requestid" in R.headers + + +def test_resolve_all(monkeypatch, monkeyed_requests_put, monkeyed_get_connection_info_from_consul, expected_config): + monkeypatch.setattr('requests.put', monkeyed_requests_put) + allk = client.resolve_all( + "test_service_component_name.unknown.unknown.unknown.dcae.onap.org") + withstuff = {'config': {'my': 'amazing config'}, + 'dti': {'my': 'dti'}, + 'policies': {'items': [{'policyName': 'DCAE_alex.Config_MS_alex_microservice.132.xml', 'policyConfigMessage': 'Config Retrieved! ', 'responseAttributes': {}, 'policyConfigStatus': 'CONFIG_RETRIEVED', 'matchingConditions': {'ONAPName': 'DCAE', 'Name': 'DCAE', 'ConfigName': 'alex_config_name'}, 'config': {'policyScope': 'alex_policy_scope', 'configName': 'alex_config_name', 'description': 'test DCAE policy-handler', 'service': 'alex_service', 'policyName': 'alex_policy_name', 'riskLevel': '3', 'key1': 'value1', 'policy_hello': 'world!', 'content': {'foo': 'microservice3', 'foo_updated': '2018-01-30T13:25:33.222Z'}, 'riskType': '1712_ETE', 'guard': 'False', 'version': '0.0.1', 'location': 'Central', 'policy_updated_ts': '2018-02-19T15:09:55.217Z', 'updated_policy_id': 'DCAE_alex.Config_MS_alex_microservice', 'policy_updated_to_ver': '132', 'priority': '4', 'policy_updated_from_ver': '131', 'templateVersion': '2', 'uuid': '5e87d7c5-0daf-4b6b-ab92-5365cf5db1ef'}, 'property': None, 'type': 'JSON', 'policyVersion': '132'}, {'policyName': 'DCAE_alex.Config_db_client_policy_id_value.133.xml', 'policyConfigMessage': 'Config Retrieved! ', 'responseAttributes': {}, 'policyConfigStatus': 'CONFIG_RETRIEVED', 'matchingConditions': {'ONAPName': 'DCAE', 'Name': 'DCAE', 'ConfigName': 'alex_config_name'}, 'config': {'db_client_ts': '2017-11-21T12:12:13.696Z', 'db_client': 'ipsum', 'policy_hello': 'world!', 'policy_updated_from_ver': '132', 'updated_policy_id': 'DCAE_alex.Config_db_client_policy_id_value', 'policy_updated_ts': '2018-02-19T15:09:55.812Z', 'policy_updated_to_ver': '133'}, 'property': None, 'type': 'JSON', 'policyVersion': '133'}], 'event': {'action': 'gathered', 'timestamp': '2018-02-19 15:36:44.877380', 'update_id': 'bb73c20a-5ff8-450f-8223-da6720ade267', 'policies_count': 2}}} + assert allk == withstuff + + monkeypatch.setattr('config_binding_service.client._get_connection_info_from_consul', + monkeyed_get_connection_info_from_consul) + allk = client.resolve_all("test_resolve_scn") + assert allk == {"config": expected_config} + + monkeypatch.setattr('connexion.request', FakeConnexion({}, "/service_component_all", "mytestingmachine", "myremoteclient")) + + R = controller.bind_all( + "test_service_component_name.unknown.unknown.unknown.dcae.onap.org") + assert(json.loads(R.data) == withstuff) + assert(R.status_code == 200) + assert "x-onap-requestid" in R.headers + + R = controller.bind_all("test_resolve_scn") + assert(json.loads(R.data) == {"config": expected_config}) + assert(R.status_code == 200) + + R = controller.bind_all("scn_NOTexists") + assert(R.status_code == 404) + assert "x-onap-requestid" in R.headers + + R = controller.bind_all("asdfasdf") + assert(R.status_code == 500) diff --git a/app/uwsgi.ini b/app/uwsgi.ini new file mode 100644 index 0000000..f514897 --- /dev/null +++ b/app/uwsgi.ini @@ -0,0 +1,3 @@ +[uwsgi] +module = app.main +callable = app diff --git a/config_binding_service/__init__.py b/config_binding_service/__init__.py deleted file mode 100644 index 5a6b081..0000000 --- a/config_binding_service/__init__.py +++ /dev/null @@ -1,40 +0,0 @@ -# ============LICENSE_START======================================================= -# Copyright (c) 2017-2018 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 os - - -class BadEnviornmentENVNotFound(Exception): - """ - Specific exception to be raised when a required ENV varaible is missing - """ - pass - - -def get_consul_uri(): - """ - This method waterfalls reads an envioronmental variable called CONSUL_HOST - If that doesn't work, it raises an Exception - """ - if "CONSUL_HOST" in os.environ: - # WARNING! TODO! Currently the env file does not include the port. - # But some other people think that the port should be a part of that. - # For now, I'm hardcoding 8500 until this gets resolved. - return "http://{0}:{1}".format(os.environ["CONSUL_HOST"], 8500) - else: - raise BadEnviornmentENVNotFound("CONSUL_HOST") diff --git a/config_binding_service/client.py b/config_binding_service/client.py deleted file mode 100644 index a87c3bc..0000000 --- a/config_binding_service/client.py +++ /dev/null @@ -1,279 +0,0 @@ -# ============LICENSE_START======================================================= -# Copyright (c) 2017-2018 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 re -from functools import partial, reduce -import base64 -import copy -import json -import requests -import six -from config_binding_service import get_consul_uri -from config_binding_service.logging import LOGGER - - -CONSUL = get_consul_uri() - -template_match_rels = re.compile("\{{2}([^\}\{]*)\}{2}") -template_match_dmaap = re.compile("<{2}([^><]*)>{2}") - -### -# Cusom Exception -### - - -class CantGetConfig(Exception): - """ - Represents an exception where a required key in consul isn't there - """ - - def __init__(self, code, response): - self.code = code - self.response = response - - -class BadRequest(Exception): - """ - Exception to be raised when the user tried to do something they shouldn't - """ - - def __init__(self, response): - self.code = 400 - self.response = response - - -### -# Private Functions -### - - -def _consul_get_all_as_transaction(service_component_name): - """ - Use Consul's transaction API to get all keys of the form service_component_name:* - Return a dict with all the values decoded - """ - payload = [ - { - "KV": { - "Verb": "get-tree", - "Key": service_component_name, - } - }] - - response = requests.put("{0}/v1/txn".format(CONSUL), json=payload) - - try: - response.raise_for_status() - except requests.exceptions.HTTPError as exc: - raise CantGetConfig(exc.response.status_code, exc.response.text) - - result = json.loads(response.text)['Results'] - - new_res = {} - for res in result: - key = res["KV"]["Key"] - val = base64.b64decode(res["KV"]["Value"]).decode("utf-8") - try: - new_res[key] = json.loads(val) - except json.decoder.JSONDecodeError: - new_res[key] = "INVALID JSON" # TODO, should we just include the original value somehow? - - if service_component_name not in new_res: - raise CantGetConfig(404, "") - - return new_res - - -def _get_config_rels_dmaap(service_component_name): - allk = _consul_get_all_as_transaction(service_component_name) - config = allk[service_component_name] - rels = allk.get(service_component_name + ":rels", []) - dmaap = allk.get(service_component_name + ":dmaap", {}) - return config, rels, dmaap - - -def _get_connection_info_from_consul(service_component_name): - """ - Call consul's catalog - TODO: currently assumes there is only one service - - TODO: WARNING: FIXTHIS: CALLINTHENATIONALARMY: - This tries to determine that a service_component_name is a cdap application by inspecting service_component_name and name munging. However, this would force all CDAP applications to have cdap_app in their name. A much better way to do this is to do some kind of catalog_lookup here, OR MAYBE change this API so that the component_type is passed in somehow. THis is a gaping TODO. - """ - LOGGER.info("Retrieving connection information for %s", service_component_name) - res = requests.get( - "{0}/v1/catalog/service/{1}".format(CONSUL, service_component_name)) - res.raise_for_status() - services = res.json() - if services == []: - LOGGER.info("Warning: config and rels keys were both valid, but there is no component named %s registered in Consul!", service_component_name) - return None # later will get filtered out - ip_addr = services[0]["ServiceAddress"] - port = services[0]["ServicePort"] - if "cdap_app" in service_component_name: - redirectish_url = "http://{0}:{1}/application/{2}".format( - ip_addr, port, service_component_name) - LOGGER.info("component is a CDAP application; trying the broker redirect on %s", redirectish_url) - res = requests.get(redirectish_url) - res.raise_for_status() - details = res.json() - # Pick out the details to expose to the component developers. These keys come from the broker API - return {key: details[key] for key in ["connectionurl", "serviceendpoints"]} - return "{0}:{1}".format(ip_addr, port) - - -def _replace_rels_template(rels, template_identifier): - """ - The magic. Replaces a template identifier {{...}} with the entrie(s) from the rels keys - NOTE: There was a discussion over whether the CBS should treat {{}} as invalid. Mike asked that - it resolve to the empty list. So, it does resolve it to empty list. - """ - returnl = [] - for rel in rels: - if template_identifier in rel and template_identifier is not "": - returnl.append(rel) - # returnl now contains a list of DNS names (possible empty), now resolve them (or not if they are not regustered) - return list(filter(lambda x: x is not None, map(_get_connection_info_from_consul, returnl))) - - -def _replace_dmaap_template(dmaap, template_identifier): - """ - This one liner could have been just put inline in the caller but maybe this will get more complex in future - Talked to Mike, default value if key is not found in dmaap key should be {} - """ - return {} if (template_identifier not in dmaap or template_identifier == "<<>>") else dmaap[template_identifier] - - -def _replace_value(v, rels, dmaap): - """ - Takes a value v that was some value in the templatized configuration, determines whether it needs replacement (either {{}} or <<>>), and if so, replaces it. - Otherwise just returns v - - implementation notes: - - the split below sees if we have v = x,y,z... so we can support {{x,y,z,....}} - - the lambda is because we can't fold operators in Python, wanted fold(+, L) where + when applied to lists in python is list concatenation - """ - if isinstance(v, six.string_types): # do not try to replace anything that is not a string - match_on_rels = re.match(template_match_rels, v) - if match_on_rels: - # now holds just x,.. of {{x,...}} - template_identifier = match_on_rels.groups()[0].strip() - rtpartial = partial(_replace_rels_template, rels) - return reduce(lambda a, b: a + b, map(rtpartial, template_identifier.split(",")), []) - match_on_dmaap = re.match(template_match_dmaap, v) - if match_on_dmaap: - template_identifier = match_on_dmaap.groups()[0].strip() - """ - Here is what Mike said: - 1) want simple replacement of "<< >>" with dmaap key value - 2) never need to support <> whereas we do support {{sct1,sct2}} - The consequence is that if you give the CBS a dmaap key like {"foo" : {...}} you are going to get back {...}, but rels always returns [...]. - So now component developers have to possible handle dicts and [], and we have to communicate that to them - """ - return _replace_dmaap_template(dmaap, template_identifier) - return v # was not a match or was not a string, return value as is - - -def _recurse(config, rels, dmaap): - """ - Recurse throug a configuration, or recursively a sub elemebt of it. - If it's a dict: recurse over all the values - If it's a list: recurse over all the values - If it's a string: return the replacement - If none of the above, just return the item. - """ - if isinstance(config, list): - return [_recurse(item, rels, dmaap) for item in config] - if isinstance(config, dict): - for key in config: - config[key] = _recurse(config[key], rels, dmaap) - return config - if isinstance(config, six.string_types): - return _replace_value(config, rels, dmaap) - # not a dict, not a list, not a string, nothing to do. - return config - - -######### -# PUBLIC API -######### - - -def resolve(service_component_name): - """ - Return the bound config of service_component_name - """ - config, rels, dmaap = _get_config_rels_dmaap(service_component_name) - return _recurse(config, rels, dmaap) - - -def resolve_override(config, rels=[], dmaap={}): - """ - Explicitly take in a config, rels, dmaap and try to resolve it. - Useful for testing where you dont want to put the test values in consul - """ - # use deepcopy to make sure that config is not touched - return _recurse(copy.deepcopy(config), rels, dmaap) - - -def resolve_all(service_component_name): - """ - Return config, policies, and any other k such that service_component_name:k exists (other than :dmaap and :rels) - """ - allk = _consul_get_all_as_transaction(service_component_name) - returnk = {} - - # replace the config with the resolved config - returnk["config"] = resolve(service_component_name) - - # concatenate the items - for k in allk: - if "policies" in k: - if "policies" not in returnk: - returnk["policies"] = {} - returnk["policies"]["event"] = {} - returnk["policies"]["items"] = [] - - if k.endswith(":policies/event"): - returnk["policies"]["event"] = allk[k] - elif ":policies/items" in k: - returnk["policies"]["items"].append(allk[k]) - else: - if not(k == service_component_name or k.endswith(":rels") or k.endswith(":dmaap")): - # this would blow up if you had a key in consul without a : but this shouldnt happen - suffix = k.split(":")[1] - returnk[suffix] = allk[k] - - return returnk - - -def get_key(key, service_component_name): - """ - Try to fetch a key k from Consul of the form service_component_name:k - """ - if key == "policies": - raise BadRequest( - ":policies is a complex folder and should be retrieved using the service_component_all API") - response = requests.get( - "{0}/v1/kv/{1}:{2}".format(CONSUL, service_component_name, key)) - try: - response.raise_for_status() - except requests.exceptions.HTTPError as exc: - raise CantGetConfig(exc.response.status_code, exc.response.text) - rest = json.loads(response.text)[0] - return json.loads(base64.b64decode(rest["Value"]).decode("utf-8")) diff --git a/config_binding_service/controller.py b/config_binding_service/controller.py deleted file mode 100644 index ec1fb86..0000000 --- a/config_binding_service/controller.py +++ /dev/null @@ -1,105 +0,0 @@ -# ============LICENSE_START======================================================= -# Copyright (c) 2017-2018 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 -import requests -import connexion -import uuid -from flask import Response -from config_binding_service import client, get_consul_uri -from config_binding_service.logging import LOGGER, audit, utc - - -def _get_helper(json_expecting_func, **kwargs): - """ - Helper function used by several functions below - """ - try: - payload = json_expecting_func(**kwargs) - response, status_code, mimetype = json.dumps(payload), 200, "application/json" - except client.BadRequest as exc: - response, status_code, mimetype = exc.response, exc.code, "text/plain" - except client.CantGetConfig as exc: - response, status_code, mimetype = exc.response, exc.code, "text/plain" - except Exception as exc: - LOGGER.error(exc) - response, status_code, mimetype = "Unknown error, please report", 500, "text/plain" - return response, status_code, mimetype - - -def _get_or_generate_xer(raw_request): - """get or generate the transaction id""" - rid = raw_request.headers.get("x-onap-requestid", None) - if rid is None: - # some components are still using the old name - rid = raw_request.headers.get("x-ecomp-requestid", None) - if rid is None: - # the user did NOT supply a request id, generate one - rid = str(uuid.uuid4()) - return rid - - -def bind_all(service_component_name): - """ - Get all the keys in Consul for this SCN, and bind the config - """ - rid = _get_or_generate_xer(connexion.request) - bts = utc() - response, status_code, mimetype = _get_helper(client.resolve_all, service_component_name=service_component_name) - audit(connexion.request, bts, rid, status_code, __name__) - # Even though some older components might be using the ecomp name, we return the proper one - return Response(response=response, status=status_code, mimetype=mimetype, headers={"x-onap-requestid": rid}) - - -def bind_config_for_scn(service_component_name): - """ - Bind just the config for this SCN - """ - print(connexion) - print(connexion.request) - rid = _get_or_generate_xer(connexion.request) - bts = utc() - response, status_code, mimetype = _get_helper(client.resolve, service_component_name=service_component_name) - audit(connexion.request, bts, rid, status_code, __name__) - return Response(response=response, status=status_code, mimetype=mimetype, headers={"x-onap-requestid": rid}) - - -def get_key(key, service_component_name): - """ - Get a single key k of the form service_component_name:k from Consul. - Should not be used and will return a BAD REQUEST for k=policies because it's a complex object - """ - rid = _get_or_generate_xer(connexion.request) - bts = utc() - response, status_code, mimetype = _get_helper(client.get_key, key=key, service_component_name=service_component_name) - audit(connexion.request, bts, rid, status_code, __name__) - return Response(response=response, status=status_code, mimetype=mimetype, headers={"x-onap-requestid": rid}) - - -def healthcheck(): - """ - CBS Healthcheck - """ - LOGGER.info("healthcheck called") - res = requests.get( - "{0}/v1/catalog/service/config_binding_service".format(get_consul_uri())) - if res.status_code == 200: - return Response(response="CBS is alive and Consul connection OK", - status=200) - return Response(response="CBS is alive but cannot reach Consul", - status=503) diff --git a/config_binding_service/logging.py b/config_binding_service/logging.py deleted file mode 100644 index edd8c1f..0000000 --- a/config_binding_service/logging.py +++ /dev/null @@ -1,94 +0,0 @@ -# ============LICENSE_START======================================================= -# Copyright (c) 2017-2018 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 logging import getLogger, StreamHandler, Formatter -from logging.handlers import RotatingFileHandler -from os import makedirs -import datetime - - -LOGGER = getLogger("defaultlogger") - - -def _create_logger(name, logfile): - """ - Create a RotatingFileHandler and a streamhandler for stdout - https://docs.python.org/3/library/logging.handlers.html - what's with the non-pythonic naming in these stdlib methods? Shameful. - """ - logger = getLogger(name) - file_handler = RotatingFileHandler(logfile, - maxBytes=10000000, backupCount=2) # 10 meg with one backup.. - formatter = Formatter('%(message)s') - file_handler.setFormatter(formatter) - stream_handler = StreamHandler() - stream_handler.setFormatter(formatter) - logger.setLevel("DEBUG") # a function is going to wrap this anyway - logger.addHandler(file_handler) - logger.addHandler(stream_handler) - return logger - - -def create_logger(): - """ - Public method to set the global logger, launched from Run - """ - LOGFILE = "/opt/logs/log.log" - makedirs("/opt/logs", exist_ok=True) - open(LOGFILE, 'a').close() # this is like "touch" - global LOGGER - LOGGER = _create_logger("config_binding_service", LOGFILE) - - -def utc(): - """gets current time in utc""" - return datetime.datetime.utcnow() - - -def audit(raw_request, bts, xer, rcode, calling_mod, msg="n/a"): - """ - write an EELF audit record per https://wiki.onap.org/download/attachments/1015849/ONAP%20application%20logging%20guidelines.pdf?api=v2 - %The audit fields implemented: - - 1 BeginTimestamp Implemented (bts) - 2 EndTimestamp Auto Injected when this is called - 3 RequestID Implemented (xer) - 7 serviceName Implemented (from Req) - 9 StatusCode Auto injected based on rcode - 10 ResponseCode Implemented (rcode) - 15 Server IP address Implemented (from Req) - 16 ElapsedTime Auto Injected (milliseconds) - 18 ClientIPaddress Implemented (from Req) - 19 class name Implemented (mod), though docs say OOP, I am using the python module here - 20 Unused ...implemented.... - 21-25 Custom n/a - 26 detailMessage Implemented (msg) - """ - ets = utc() - - LOGGER.info("{bts}|{ets}|{xer}||||{path}||{status}|{rcode}|||||{servip}|{et}||{clientip}|{calling_mod}|||||||{msg}".format( - bts=bts.isoformat(), - ets=ets.isoformat(), - xer=xer, rcode=rcode, - path=raw_request.path.split("/")[1], - status="COMPLETE" if rcode == 200 else "ERROR", - servip=raw_request.host.split(":")[0], - et=int((ets - bts).microseconds / 1000), # supposed to be in milleseconds - clientip=raw_request.remote_addr, - calling_mod=calling_mod, msg=msg - )) diff --git a/config_binding_service/run.py b/config_binding_service/run.py deleted file mode 100644 index 476dc87..0000000 --- a/config_binding_service/run.py +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env python3 - -# ============LICENSE_START======================================================= -# Copyright (c) 2017-2018 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 connexion -from config_binding_service.logging import create_logger, LOGGER - - -def main(): - """CBS Entrypoint""" - create_logger() - try: - app = connexion.App(__name__, specification_dir='.') - app.add_api('swagger.yaml', arguments={'title': 'Config Binding Service'}) - app.run(host='0.0.0.0', port=10000, debug=False) - except Exception as exc: - LOGGER.error("Fatal error. Could not start webserver due to: %s", exc) diff --git a/config_binding_service/swagger.yaml b/config_binding_service/swagger.yaml deleted file mode 100644 index cfe0944..0000000 --- a/config_binding_service/swagger.yaml +++ /dev/null @@ -1,100 +0,0 @@ -# ============LICENSE_START======================================================= -# Copyright (c) 2017-2018 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. - - ---- -swagger: "2.0" -info: - version: "2.1.0" - title: "Config Binding Service" -paths: - /service_component/{service_component_name}: - parameters: - - name: "service_component_name" - in: "path" - description: "Service Component Name. service_component_name must be a key in consul." - required: true - type: "string" - get: - description: "Binds the configuration for service_component_name and returns the bound configuration as a JSON" - operationId: "config_binding_service.controller.bind_config_for_scn" - responses: - 200: - description: OK; the bound config is returned as an object - schema: - type: object - 404: - description: there is no configuration in Consul for this component - - /service_component_all/{service_component_name}: - parameters: - - name: "service_component_name" - in: "path" - description: "Service Component Name. service_component_name must be a key in consul." - required: true - type: "string" - get: - description: "Binds the configuration for service_component_name and returns the bound configuration, policies, and any other keys that are in Consul" - operationId: "config_binding_service.controller.bind_all" - responses: - 200: - description: "OK; returns {config : ..., policies : ....., k : ...} for all other k in Consul" - schema: - type: object - 404: - description: there is no configuration in Consul for this component - - /{key}/{service_component_name}: - parameters: - - name: "key" - in: "path" - description: "this endpoint tries to pull service_component_name:key; key is the key after the colon" - required: true - type: "string" - - name: "service_component_name" - in: "path" - description: "Service Component Name." - required: true - type: "string" - get: - description: "this is an endpoint that fetches a generic service_component_name:key out of Consul. The idea is that we don't want to tie components to Consul directly in case we swap out the backend some day, so the CBS abstracts Consul from clients. The structuring and weird collision of this new API with the above is unfortunate but due to legacy concerns." - operationId: "config_binding_service.controller.get_key" - responses: - 200: - description: "OK; returns service_component_name:key" - schema: - type: object - 404: - description: "key does not exist" - schema: - type: string - 400: - description: "bad request. Currently this is only returned on :policies, which is a complex object, and should be gotten through service_component_all" - schema: - type: string - - /healthcheck: - get: - description: "This is the health check endpoint. If this returns a 200, the server is alive and consul can be reached. If not a 200, either dead, or no connection to consul" - operationId: "config_binding_service.controller.healthcheck" - parameters: [] - responses: - 200: - description: Successful response - 503: - description: the config binding service cannot reach Consul diff --git a/doc/cbs_diagram.png b/doc/cbs_diagram.png deleted file mode 100644 index 67287d0..0000000 Binary files a/doc/cbs_diagram.png and /dev/null differ diff --git a/setup.py b/setup.py deleted file mode 100644 index 5117bd8..0000000 --- a/setup.py +++ /dev/null @@ -1,38 +0,0 @@ -# ============LICENSE_START======================================================= -# Copyright (c) 2017-2018 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 - -setup( - name='config_binding_service', - version='2.1.5', - packages=find_packages(exclude=["tests.*", "tests"]), - author="Tommy Carpenter", - author_email="tommy@research.att.com", - description='Service to fetch and bind configurations', - license="", - keywords="", - url="https://gerrit.onap.org/r/#/admin/projects/dcaegen2/platform/configbinding", - zip_safe=False, - entry_points={ - 'console_scripts': [ - 'run.py=config_binding_service.run:main'] - }, - install_requires=["requests", "Flask", "connexion", "six"], - include_package_data=True -) diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index 1875bf6..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -# ================================================================================ -# Copyright (c) 2018 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. - - -# empty __init__.py so that pytest can add correct path to coverage report, -- per pytest -# best practice guideline diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index c8f2a06..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,70 +0,0 @@ -import pytest -from requests.exceptions import HTTPError -from config_binding_service import get_consul_uri - - -class FakeResponse(): - def __init__(self, status_code, text): - self.text = text - self.status_code = status_code - - def raise_for_status(self): - if self.status_code >= 400: - raise HTTPError(response=FakeResponse(404, "")) - - -@pytest.fixture -def expected_config(): - return {"deep": {"ALL YOUR SERVICE BELONG TO US": ['6.6.6.6:666', '7.7.7.7:777', '5.5.5.5:555', '5.5.5.5:555']}, - "doubledeep": {"sodeep": {"hello": "darkness"}}} - - -@pytest.fixture -def monkeyed_get_connection_info_from_consul(): - def _monkeyed_get_connection_info_from_consul(service_component_name): - # shared monkeypatch. probably somewhat lazy because the function htis patches can be broken up. - if service_component_name == "cdap": - return '666.666.666.666:666' - elif service_component_name == "testing_bravo.somedomain.com": - return '7.7.7.7:777' - elif service_component_name == "testing_alpha.somedomain.com": - return '6.6.6.6:666' - elif service_component_name == "testing_charlie.somedomain.com": - return '5.5.5.5:555' - elif service_component_name == "nonexistent_hope": - return None # the real function returns None here - elif service_component_name == "cdap_serv.dcae.ecomp.somedomain.com": - broker_ip = '1.1.1.1' - broker_port = 444 - return "http://{0}:{1}/application/{2}".format(broker_ip, broker_port, service_component_name) - return _monkeyed_get_connection_info_from_consul - - -@pytest.fixture -def monkeyed_requests_get(): - def _monkeyed_requests_get(url): - if url == "{0}/v1/kv/test_service_component_name.unknown.unknown.unknown.dcae.onap.org:dti".format(get_consul_uri()): - return FakeResponse(status_code=200, text='[{"LockIndex":0,"Key":"test_service_component_name.unknown.unknown.unknown.dcae.onap.org:dti","Flags":0,"Value": "eyJteSIgOiAiZHRpIn0=","CreateIndex":4066524,"ModifyIndex":4066524}]') - else: - return FakeResponse(status_code=404, text="") - return _monkeyed_requests_get - - -@pytest.fixture -def monkeyed_requests_put(): - def _monkeyed_requests_put(url, json): - if url == "{0}/v1/txn".format(get_consul_uri()): - key = json[0]["KV"]["Key"] - if key == "test_service_component_name.unknown.unknown.unknown.dcae.onap.org": - return FakeResponse(status_code=200, text='{"Results":[{"KV":{"LockIndex":0,"Key":"test_service_component_name.unknown.unknown.unknown.dcae.onap.org","Flags":0,"Value":"eyJteSIgOiAiYW1hemluZyBjb25maWcifQ==","CreateIndex":4051555,"ModifyIndex":4051555}},{"KV":{"LockIndex":0,"Key":"test_service_component_name.unknown.unknown.unknown.dcae.onap.org:dmaap","Flags":0,"Value":"eyJmb28iIDogImJhciJ9","CreateIndex":4051571,"ModifyIndex":4051571}},{"KV":{"LockIndex":0,"Key":"test_service_component_name.unknown.unknown.unknown.dcae.onap.org:dti","Flags":0,"Value":"eyJteSIgOiAiZHRpIn0=","CreateIndex":4066524,"ModifyIndex":4066524}},{"KV":{"LockIndex":0,"Key":"test_service_component_name.unknown.unknown.unknown.dcae.onap.org:policies/event","Flags":0,"Value":"eyJhY3Rpb24iOiAiZ2F0aGVyZWQiLCAidGltZXN0YW1wIjogIjIwMTgtMDItMTkgMTU6MzY6NDQuODc3MzgwIiwgInVwZGF0ZV9pZCI6ICJiYjczYzIwYS01ZmY4LTQ1MGYtODIyMy1kYTY3MjBhZGUyNjciLCAicG9saWNpZXNfY291bnQiOiAyfQ==","CreateIndex":4048564,"ModifyIndex":4048564}},{"KV":{"LockIndex":0,"Key":"test_service_component_name.unknown.unknown.unknown.dcae.onap.org:policies/items/DCAE_alex.Config_MS_alex_microservice","Flags":0,"Value":"eyJwb2xpY3lOYW1lIjogIkRDQUVfYWxleC5Db25maWdfTVNfYWxleF9taWNyb3NlcnZpY2UuMTMyLnhtbCIsICJwb2xpY3lDb25maWdNZXNzYWdlIjogIkNvbmZpZyBSZXRyaWV2ZWQhICIsICJyZXNwb25zZUF0dHJpYnV0ZXMiOiB7fSwgInBvbGljeUNvbmZpZ1N0YXR1cyI6ICJDT05GSUdfUkVUUklFVkVEIiwgIm1hdGNoaW5nQ29uZGl0aW9ucyI6IHsiT05BUE5hbWUiOiAiRENBRSIsICJOYW1lIjogIkRDQUUiLCAiQ29uZmlnTmFtZSI6ICJhbGV4X2NvbmZpZ19uYW1lIn0sICJjb25maWciOiB7InBvbGljeVNjb3BlIjogImFsZXhfcG9saWN5X3Njb3BlIiwgImNvbmZpZ05hbWUiOiAiYWxleF9jb25maWdfbmFtZSIsICJkZXNjcmlwdGlvbiI6ICJ0ZXN0IERDQUUgcG9saWN5LWhhbmRsZXIiLCAic2VydmljZSI6ICJhbGV4X3NlcnZpY2UiLCAicG9saWN5TmFtZSI6ICJhbGV4X3BvbGljeV9uYW1lIiwgInJpc2tMZXZlbCI6ICIzIiwgImtleTEiOiAidmFsdWUxIiwgInBvbGljeV9oZWxsbyI6ICJ3b3JsZCEiLCAiY29udGVudCI6IHsiZm9vIjogIm1pY3Jvc2VydmljZTMiLCAiZm9vX3VwZGF0ZWQiOiAiMjAxOC0wMS0zMFQxMzoyNTozMy4yMjJaIn0sICJyaXNrVHlwZSI6ICIxNzEyX0VURSIsICJndWFyZCI6ICJGYWxzZSIsICJ2ZXJzaW9uIjogIjAuMC4xIiwgImxvY2F0aW9uIjogIkNlbnRyYWwiLCAicG9saWN5X3VwZGF0ZWRfdHMiOiAiMjAxOC0wMi0xOVQxNTowOTo1NS4yMTdaIiwgInVwZGF0ZWRfcG9saWN5X2lkIjogIkRDQUVfYWxleC5Db25maWdfTVNfYWxleF9taWNyb3NlcnZpY2UiLCAicG9saWN5X3VwZGF0ZWRfdG9fdmVyIjogIjEzMiIsICJwcmlvcml0eSI6ICI0IiwgInBvbGljeV91cGRhdGVkX2Zyb21fdmVyIjogIjEzMSIsICJ0ZW1wbGF0ZVZlcnNpb24iOiAiMiIsICJ1dWlkIjogIjVlODdkN2M1LTBkYWYtNGI2Yi1hYjkyLTUzNjVjZjVkYjFlZiJ9LCAicHJvcGVydHkiOiBudWxsLCAidHlwZSI6ICJKU09OIiwgInBvbGljeVZlcnNpb24iOiAiMTMyIn0=","CreateIndex":4048564,"ModifyIndex":4065574}},{"KV":{"LockIndex":0,"Key":"test_service_component_name.unknown.unknown.unknown.dcae.onap.org:policies/items/DCAE_alex.Config_db_client_policy_id_value","Flags":0,"Value":"eyJwb2xpY3lOYW1lIjogIkRDQUVfYWxleC5Db25maWdfZGJfY2xpZW50X3BvbGljeV9pZF92YWx1ZS4xMzMueG1sIiwgInBvbGljeUNvbmZpZ01lc3NhZ2UiOiAiQ29uZmlnIFJldHJpZXZlZCEgIiwgInJlc3BvbnNlQXR0cmlidXRlcyI6IHt9LCAicG9saWN5Q29uZmlnU3RhdHVzIjogIkNPTkZJR19SRVRSSUVWRUQiLCAibWF0Y2hpbmdDb25kaXRpb25zIjogeyJPTkFQTmFtZSI6ICJEQ0FFIiwgIk5hbWUiOiAiRENBRSIsICJDb25maWdOYW1lIjogImFsZXhfY29uZmlnX25hbWUifSwgImNvbmZpZyI6IHsiZGJfY2xpZW50X3RzIjogIjIwMTctMTEtMjFUMTI6MTI6MTMuNjk2WiIsICJkYl9jbGllbnQiOiAiaXBzdW0iLCAicG9saWN5X2hlbGxvIjogIndvcmxkISIsICJwb2xpY3lfdXBkYXRlZF9mcm9tX3ZlciI6ICIxMzIiLCAidXBkYXRlZF9wb2xpY3lfaWQiOiAiRENBRV9hbGV4LkNvbmZpZ19kYl9jbGllbnRfcG9saWN5X2lkX3ZhbHVlIiwgInBvbGljeV91cGRhdGVkX3RzIjogIjIwMTgtMDItMTlUMTU6MDk6NTUuODEyWiIsICJwb2xpY3lfdXBkYXRlZF90b192ZXIiOiAiMTMzIn0sICJwcm9wZXJ0eSI6IG51bGwsICJ0eXBlIjogIkpTT04iLCAicG9saWN5VmVyc2lvbiI6ICIxMzMifQ==","CreateIndex":4048564,"ModifyIndex":4065570}},{"KV":{"LockIndex":0,"Key":"test_service_component_name.unknown.unknown.unknown.dcae.onap.org:rels","Flags":0,"Value":"WyJteS5hbWF6aW5nLnJlbGF0aW9uc2hpcCJd","CreateIndex":4051567,"ModifyIndex":4051567}}],"Errors":null,"Index":0,"LastContact":0,"KnownLeader":true}') - elif key == "scn_exists": - return FakeResponse(status_code=200, text='{"Results":[{"KV":{"LockIndex":0,"Key":"scn_exists","Flags":0,"Value":"eyJmb28zIiA6ICJiYXIzIn0=","CreateIndex":4067403,"ModifyIndex":4067403}},{"KV":{"LockIndex":0,"Key":"scn_exists:dmaap","Flags":0,"Value":"eyJmb280IiA6ICJiYXI0In0=","CreateIndex":4067410,"ModifyIndex":4067410}},{"KV":{"LockIndex":0,"Key":"scn_exists:rels","Flags":0,"Value":"WyJmb28iXQ==","CreateIndex":4067406,"ModifyIndex":4067406}},{"KV":{"LockIndex":0,"Key":"scn_exists_nord","Flags":0,"Value":"eyJmb281IiA6ICJiYXI1In0=","CreateIndex":4067340,"ModifyIndex":4067340}}],"Errors":null,"Index":0,"LastContact":0,"KnownLeader":true}') - elif key == "scn_exists_nord": - return FakeResponse(status_code=200, text='{"Results":[{"KV":{"LockIndex":0,"Key":"scn_exists_nord","Flags":0,"Value":"eyJmb281IiA6ICJiYXI1In0=","CreateIndex":4067340,"ModifyIndex":4067340}}],"Errors":null,"Index":0,"LastContact":0,"KnownLeader":true}') - elif key == "test_resolve_scn": - return FakeResponse(status_code=200, text='{"Results":[{"KV":{"LockIndex":0,"Key":"test_resolve_scn","Flags":0,"Value":"ewogICAgICAgICAgICAgICAgImRlZXAiIDogewogICAgICAgICAgICAgICAgICAgICJBTEwgWU9VUiBTRVJWSUNFIEJFTE9ORyBUTyBVUyIgOiAie3thbHBoYSxicmF2byxjaGFybGllfX0ifSwKICAgICAgICAgICAgICAgICJkb3VibGVkZWVwIiA6ICB7CiAgICAgICAgICAgICAgICAgICAgInNvZGVlcCIgOiB7ImhlbGxvIiA6ICI8PFdITz8+PiJ9fQogICAgICAgICAgICAgfQo=","CreateIndex":4068002,"ModifyIndex":4068002}},{"KV":{"LockIndex":0,"Key":"test_resolve_scn:dmaap","Flags":0,"Value":"eyJXSE8/IiA6ICJkYXJrbmVzcyJ9","CreateIndex":4068013,"ModifyIndex":4068013}},{"KV":{"LockIndex":0,"Key":"test_resolve_scn:rels","Flags":0,"Value":"WyJ0ZXN0aW5nX2FscGhhLnNvbWVkb21haW4uY29tIiwgInRlc3RpbmdfYnJhdm8uc29tZWRvbWFpbi5jb20iLCAidGVzdGluZ19jaGFybGllLnNvbWVkb21haW4uY29tIiwgInRlc3RpbmdfY2hhcmxpZS5zb21lZG9tYWluLmNvbSJd","CreateIndex":4068010,"ModifyIndex":4068010}}],"Errors":null,"Index":0,"LastContact":0,"KnownLeader":true}') - elif key == "cbs_test_messed_up": - return FakeResponse(status_code=200, text='{"Results":[{"KV":{"LockIndex":0,"Key":"cbs_test_messed_up","Flags":0,"Value":"eyJmb28iIDogImJhciJ9","CreateIndex":4864032,"ModifyIndex":4864052}},{"KV":{"LockIndex":0,"Key":"cbs_test_messed_up:badkey","Flags":0,"Value":"eyJub3QgYSBqc29ubm5uIFJFS1RUVFQ=","CreateIndex":4864075,"ModifyIndex":4864075}}],"Errors":null,"Index":0,"LastContact":0,"KnownLeader":true}') - elif key == "scn_NOTexists": - return FakeResponse(status_code=404, text="") - return _monkeyed_requests_put diff --git a/tests/test_client.py b/tests/test_client.py deleted file mode 100644 index 06a3dc2..0000000 --- a/tests/test_client.py +++ /dev/null @@ -1,159 +0,0 @@ -from config_binding_service import client - - -def test_consul_get_all_as_transaction(monkeypatch, monkeyed_requests_put): - """tests _consul_get_all_as_transaction""" - monkeypatch.setattr('requests.put', monkeyed_requests_put) - allk = client._consul_get_all_as_transaction( - "test_service_component_name.unknown.unknown.unknown.dcae.onap.org") - assert allk == { - 'test_service_component_name.unknown.unknown.unknown.dcae.onap.org': {'my': 'amazing config'}, - 'test_service_component_name.unknown.unknown.unknown.dcae.onap.org:dti': {'my': 'dti'}, - 'test_service_component_name.unknown.unknown.unknown.dcae.onap.org:dmaap': {'foo': 'bar'}, - 'test_service_component_name.unknown.unknown.unknown.dcae.onap.org:policies/event': {'action': 'gathered', 'timestamp': '2018-02-19 15:36:44.877380', 'update_id': 'bb73c20a-5ff8-450f-8223-da6720ade267', 'policies_count': 2}, - 'test_service_component_name.unknown.unknown.unknown.dcae.onap.org:policies/items/DCAE_alex.Config_MS_alex_microservice': {'policyName': 'DCAE_alex.Config_MS_alex_microservice.132.xml', 'policyConfigMessage': 'Config Retrieved! ', 'responseAttributes': {}, 'policyConfigStatus': 'CONFIG_RETRIEVED', 'matchingConditions': {'ONAPName': 'DCAE', 'Name': 'DCAE', 'ConfigName': 'alex_config_name'}, 'config': {'policyScope': 'alex_policy_scope', 'configName': 'alex_config_name', 'description': 'test DCAE policy-handler', 'service': 'alex_service', 'policyName': 'alex_policy_name', 'riskLevel': '3', 'key1': 'value1', 'policy_hello': 'world!', 'content': {'foo': 'microservice3', 'foo_updated': '2018-01-30T13:25:33.222Z'}, 'riskType': '1712_ETE', 'guard': 'False', 'version': '0.0.1', 'location': 'Central', 'policy_updated_ts': '2018-02-19T15:09:55.217Z', 'updated_policy_id': 'DCAE_alex.Config_MS_alex_microservice', 'policy_updated_to_ver': '132', 'priority': '4', 'policy_updated_from_ver': '131', 'templateVersion': '2', 'uuid': '5e87d7c5-0daf-4b6b-ab92-5365cf5db1ef'}, 'property': None, 'type': 'JSON', 'policyVersion': '132'}, - 'test_service_component_name.unknown.unknown.unknown.dcae.onap.org:policies/items/DCAE_alex.Config_db_client_policy_id_value': {'policyName': 'DCAE_alex.Config_db_client_policy_id_value.133.xml', 'policyConfigMessage': 'Config Retrieved! ', 'responseAttributes': {}, 'policyConfigStatus': 'CONFIG_RETRIEVED', 'matchingConditions': {'ONAPName': 'DCAE', 'Name': 'DCAE', 'ConfigName': 'alex_config_name'}, 'config': {'db_client_ts': '2017-11-21T12:12:13.696Z', 'db_client': 'ipsum', 'policy_hello': 'world!', 'policy_updated_from_ver': '132', 'updated_policy_id': 'DCAE_alex.Config_db_client_policy_id_value', 'policy_updated_ts': '2018-02-19T15:09:55.812Z', 'policy_updated_to_ver': '133'}, 'property': None, 'type': 'JSON', 'policyVersion': '133'}, - 'test_service_component_name.unknown.unknown.unknown.dcae.onap.org:rels': ['my.amazing.relationship'] - } - - allk = client._consul_get_all_as_transaction("cbs_test_messed_up") - assert allk == {'cbs_test_messed_up': {'foo': 'bar'}, - 'cbs_test_messed_up:badkey': 'INVALID JSON'} - - -def test_get_config_rels_dmaap(monkeypatch, monkeyed_requests_put): - monkeypatch.setattr('requests.put', monkeyed_requests_put) - assert ({"foo3": "bar3"}, ["foo"], {"foo4": "bar4"}) == client._get_config_rels_dmaap("scn_exists") - assert ({"foo5": "bar5"}, [], {}) == client._get_config_rels_dmaap("scn_exists_nord") - - -def test_bad_config_http(): - test_config = {'yeahhhhh': "{{}}"} - test_rels = ["testing_bravo.somedomain.com"] - assert {'yeahhhhh': []} == client.resolve_override(test_config, test_rels) - - -def test_bad_config_dmaap(): - test_config = {'darkness': "<<>>"} - test_dmaap = {"WHO?": "darkness"} - assert {'darkness': {}} == client.resolve_override(test_config, test_dmaap) - - -def test_config_with_list(monkeypatch, monkeyed_get_connection_info_from_consul): - monkeypatch.setattr('config_binding_service.client._get_connection_info_from_consul', - monkeyed_get_connection_info_from_consul) - test_config_1 = {"dcae_target_type": [ - "vhss-ems", "pcrf-oam"], "downstream-laika": "{{ laika }}", "some-param": "Lorem ipsum dolor sit amet"} - test_rels_1 = ["3df5292249ae4a949f173063617cea8d_docker-snmp-polling-firstnet-m"] - test_bind_1 = client.resolve_override(test_config_1, test_rels_1, {}) - assert(test_bind_1 == {'dcae_target_type': [ - 'vhss-ems', 'pcrf-oam'], 'downstream-laika': [], 'some-param': 'Lorem ipsum dolor sit amet'}) - - test_config_2 = {"foo": ["{{cdap}}", "notouching", "<>"]} - test_rels_2 = ["cdap"] - test_dmaap_2 = {"yo": "im here"} - test_bind_2 = client.resolve_override(test_config_2, test_rels_2, test_dmaap_2) - assert(test_bind_2 == {"foo": [['666.666.666.666:666'], "notouching", "im here"]}) - - -def test_cdap(monkeypatch, monkeyed_get_connection_info_from_consul): - # user override to test CDAP functionality - monkeypatch.setattr('config_binding_service.client._get_connection_info_from_consul', - monkeyed_get_connection_info_from_consul) - test_rels = ["testing_alpha.somedomain.com", "testing_bravo.somedomain.com", - "testing_charlie.somedomain.com", "testing_charlie.somedomain.com", "cdap"] - test_config = {"streams_publishes": "{{alpha}}", - # should be dumped - "services_calls": [{"somekey": "{{charlie}}"}], - "cdap_to_manage": {'some_nested_thing': "{{cdap}}"}} # no dumps - test_bind_1 = client.resolve_override(test_config, test_rels) - assert test_bind_1 == {'services_calls': [{"somekey": ["5.5.5.5:555", "5.5.5.5:555"]}], 'streams_publishes': [ - "6.6.6.6:666"], 'cdap_to_manage': {'some_nested_thing': ['666.666.666.666:666']}} - assert test_bind_1['services_calls'] == [{"somekey": ["5.5.5.5:555", "5.5.5.5:555"]}] - assert test_bind_1['streams_publishes'] == ["6.6.6.6:666"] - - -def test_multiple_service_types(monkeypatch, monkeyed_get_connection_info_from_consul): - # test {{x,y,z}} - monkeypatch.setattr('config_binding_service.client._get_connection_info_from_consul', - monkeyed_get_connection_info_from_consul) - - # test 1: they all resovle - test_rels = ["testing_alpha.somedomain.com", "testing_bravo.somedomain.com", - "testing_charlie.somedomain.com", "testing_charlie.somedomain.com"] - config = {"ALL YOUR SERVICE BELONG TO US": "{{alpha,bravo,charlie}}"} - test_bind_1 = client.resolve_override(config, test_rels) - assert(test_bind_1 == {"ALL YOUR SERVICE BELONG TO US": [ - '6.6.6.6:666', '7.7.7.7:777', '5.5.5.5:555', '5.5.5.5:555']}) - - # test 2: two resolve, one is missing from rels key - config2 = {"two there one not exist": "{{alpha,bravo,notexist}}"} - test_bind_2 = client.resolve_override(config2, test_rels) - assert(test_bind_2 == {"two there one not exist": [ - '6.6.6.6:666', '7.7.7.7:777']}) - - # test 3: two resolve, one is in rels key but not registered - config3 = {"two there one unregistered": "{{alpha,bravo,unregistered}}"} - test_rels3 = ["testing_alpha.somedomain.com", - "testing_bravo.somedomain.com", "unregistered.somedomain.com"] - test_bind_3 = client.resolve_override(config3, test_rels3) - assert(test_bind_3 == {"two there one unregistered": [ - '6.6.6.6:666', '7.7.7.7:777']}) - - -def test_dmaap(monkeypatch): - # test resolving dmaap key - config = {"TODAY IS YOUR LUCKY DAY": "<>"} - # does not match - test_bind = client.resolve_override( - config, dmaap={"XX": "ABSOLVEME"}) # XX != XXX - assert(test_bind == {"TODAY IS YOUR LUCKY DAY": {}}) - # matches - test_bind_2 = client.resolve_override(config, dmaap={"XXX": "ABSOLVEME"}) - assert(test_bind_2 == {"TODAY IS YOUR LUCKY DAY": "ABSOLVEME"}) - - -def test_config(monkeypatch, monkeyed_get_connection_info_from_consul): - # test config override - monkeypatch.setattr('config_binding_service.client._get_connection_info_from_consul', - monkeyed_get_connection_info_from_consul) - test_config = {"autoderegisterafter": "10m", "cdap_to_manage": { - 'some_nested_thing': "{{cdap}}"}, "bindingttw": 5, "hcinterval": "5s"} - test_rels = ["cdap"] - test_bind_1 = client.resolve_override(test_config, test_rels) - assert test_bind_1 == {'autoderegisterafter': '10m', 'cdap_to_manage': { - 'some_nested_thing': ['666.666.666.666:666']}, 'bindingttw': 5, 'hcinterval': '5s'} - - -def test_non_existent(monkeypatch, monkeyed_get_connection_info_from_consul): - # test a valid config-rels but the key is not in Consul - monkeypatch.setattr('config_binding_service.client._get_connection_info_from_consul', - monkeyed_get_connection_info_from_consul) - test_config = {"you shall not be fufilled": "{{nonexistent_hope}}"} - # hopefully not registered in Consul.. - test_rels = ["nonexistent_hope.rework-central.ecomp.somedomain.com"] - test_bind_1 = client.resolve_override(test_config, test_rels, {}) - assert(test_bind_1 == {"you shall not be fufilled": []}) - - -def test_broker_redirect(monkeypatch, monkeyed_get_connection_info_from_consul): - # test the broker redirect - monkeypatch.setattr('config_binding_service.client._get_connection_info_from_consul', - monkeyed_get_connection_info_from_consul) - test_config = {"gimmie_dat_cdap": "{{cdap_serv}}"} - test_rels = ["cdap_serv.dcae.ecomp.somedomain.com"] - assert {"gimmie_dat_cdap": ['http://1.1.1.1:444/application/cdap_serv.dcae.ecomp.somedomain.com'] - } == client.resolve_override(test_config, test_rels) - - -def test_both(monkeypatch, monkeyed_get_connection_info_from_consul, expected_config): - # test rels and http - monkeypatch.setattr('config_binding_service.client._get_connection_info_from_consul', - monkeyed_get_connection_info_from_consul) - test_rels = ["testing_alpha.somedomain.com", "testing_bravo.somedomain.com", - "testing_charlie.somedomain.com", "testing_charlie.somedomain.com"] - test_dmaap = {"WHO?": "darkness"} - config = {"deep": {"ALL YOUR SERVICE BELONG TO US": "{{alpha,bravo,charlie}}"}, - "doubledeep": {"sodeep": {"hello": "<>"}}} - test_bind_1 = client.resolve_override(config, test_rels, test_dmaap) - assert(test_bind_1 == expected_config) diff --git a/tests/test_controller.py b/tests/test_controller.py deleted file mode 100644 index cda1fc7..0000000 --- a/tests/test_controller.py +++ /dev/null @@ -1,113 +0,0 @@ -# ============LICENSE_START======================================================= -# Copyright (c) 2017-2018 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 -import pytest -from config_binding_service import client, controller - - -# pytest doesnt support objects in conftest yet -class FakeConnexion(object): - def __init__(self, headers, path, host, remote_addr): - self.headers = headers - self.path = path - self.host = host - self.remote_addr = remote_addr - - -def test_bind_config_for_scn(monkeypatch, monkeyed_requests_put): - monkeypatch.setattr('requests.put', monkeyed_requests_put) - monkeypatch.setattr('connexion.request', FakeConnexion({"x-onap-requestid": 123456789}, "/service_component", "mytestingmachine", "myremoteclient")) - - assert(client.resolve("scn_exists") == {"foo3": "bar3"}) - with pytest.raises(client.CantGetConfig): - client.resolve("scn_NOTexists") - - R = controller.bind_config_for_scn("scn_exists") - assert(json.loads(R.data) == {"foo3": "bar3"}) - assert(R.status_code == 200) - assert(R.headers["x-onap-requestid"] == "123456789") - - R = controller.bind_config_for_scn("scn_NOTexists") - assert(R.status_code == 404) - assert(R.headers["x-onap-requestid"] == "123456789") - - R = controller.bind_config_for_scn("asdfasdf") - assert(R.status_code == 500) - assert(R.headers["x-onap-requestid"] == "123456789") - - -def test_generic(monkeypatch, monkeyed_requests_get, monkeyed_requests_put): - monkeypatch.setattr('requests.put', monkeyed_requests_put) - monkeypatch.setattr('requests.get', monkeyed_requests_get) - assert client.get_key("dti", "test_service_component_name.unknown.unknown.unknown.dcae.onap.org") == json.loads('{"my": "dti"}') - with pytest.raises(client.CantGetConfig): - client.get_key( - "nokeyforyou", "test_service_component_name.unknown.unknown.unknown.dcae.onap.org") - - monkeypatch.setattr('connexion.request', FakeConnexion({}, "/get_key", "mytestingmachine", "myremoteclient")) - - R = controller.get_key( - "dti", "test_service_component_name.unknown.unknown.unknown.dcae.onap.org") - assert(json.loads(R.data) == {"my": "dti"}) - assert(R.status_code == 200) - assert "x-onap-requestid" in R.headers - - R = controller.get_key( - "nokeyforyou", "test_service_component_name.unknown.unknown.unknown.dcae.onap.org") - assert(R.status_code == 404) - assert "x-onap-requestid" in R.headers - - R = controller.get_key( - "policies", "test_service_component_name.unknown.unknown.unknown.dcae.onap.org") - assert(R.status_code == 400) - assert "x-onap-requestid" in R.headers - - -def test_resolve_all(monkeypatch, monkeyed_requests_put, monkeyed_get_connection_info_from_consul, expected_config): - monkeypatch.setattr('requests.put', monkeyed_requests_put) - allk = client.resolve_all( - "test_service_component_name.unknown.unknown.unknown.dcae.onap.org") - withstuff = {'config': {'my': 'amazing config'}, - 'dti': {'my': 'dti'}, - 'policies': {'items': [{'policyName': 'DCAE_alex.Config_MS_alex_microservice.132.xml', 'policyConfigMessage': 'Config Retrieved! ', 'responseAttributes': {}, 'policyConfigStatus': 'CONFIG_RETRIEVED', 'matchingConditions': {'ONAPName': 'DCAE', 'Name': 'DCAE', 'ConfigName': 'alex_config_name'}, 'config': {'policyScope': 'alex_policy_scope', 'configName': 'alex_config_name', 'description': 'test DCAE policy-handler', 'service': 'alex_service', 'policyName': 'alex_policy_name', 'riskLevel': '3', 'key1': 'value1', 'policy_hello': 'world!', 'content': {'foo': 'microservice3', 'foo_updated': '2018-01-30T13:25:33.222Z'}, 'riskType': '1712_ETE', 'guard': 'False', 'version': '0.0.1', 'location': 'Central', 'policy_updated_ts': '2018-02-19T15:09:55.217Z', 'updated_policy_id': 'DCAE_alex.Config_MS_alex_microservice', 'policy_updated_to_ver': '132', 'priority': '4', 'policy_updated_from_ver': '131', 'templateVersion': '2', 'uuid': '5e87d7c5-0daf-4b6b-ab92-5365cf5db1ef'}, 'property': None, 'type': 'JSON', 'policyVersion': '132'}, {'policyName': 'DCAE_alex.Config_db_client_policy_id_value.133.xml', 'policyConfigMessage': 'Config Retrieved! ', 'responseAttributes': {}, 'policyConfigStatus': 'CONFIG_RETRIEVED', 'matchingConditions': {'ONAPName': 'DCAE', 'Name': 'DCAE', 'ConfigName': 'alex_config_name'}, 'config': {'db_client_ts': '2017-11-21T12:12:13.696Z', 'db_client': 'ipsum', 'policy_hello': 'world!', 'policy_updated_from_ver': '132', 'updated_policy_id': 'DCAE_alex.Config_db_client_policy_id_value', 'policy_updated_ts': '2018-02-19T15:09:55.812Z', 'policy_updated_to_ver': '133'}, 'property': None, 'type': 'JSON', 'policyVersion': '133'}], 'event': {'action': 'gathered', 'timestamp': '2018-02-19 15:36:44.877380', 'update_id': 'bb73c20a-5ff8-450f-8223-da6720ade267', 'policies_count': 2}}} - assert allk == withstuff - - monkeypatch.setattr('config_binding_service.client._get_connection_info_from_consul', - monkeyed_get_connection_info_from_consul) - allk = client.resolve_all("test_resolve_scn") - assert allk == {"config": expected_config} - - monkeypatch.setattr('connexion.request', FakeConnexion({}, "/service_component_all", "mytestingmachine", "myremoteclient")) - - R = controller.bind_all( - "test_service_component_name.unknown.unknown.unknown.dcae.onap.org") - assert(json.loads(R.data) == withstuff) - assert(R.status_code == 200) - assert "x-onap-requestid" in R.headers - - R = controller.bind_all("test_resolve_scn") - assert(json.loads(R.data) == {"config": expected_config}) - assert(R.status_code == 200) - - R = controller.bind_all("scn_NOTexists") - assert(R.status_code == 404) - assert "x-onap-requestid" in R.headers - - R = controller.bind_all("asdfasdf") - assert(R.status_code == 500) diff --git a/tox-local.ini b/tox-local.ini index 8a309b1..bbf619b 100644 --- a/tox-local.ini +++ b/tox-local.ini @@ -1,6 +1,7 @@ # content of: tox.ini , put in same dir as setup.py [tox] envlist = py36,flake8 +setupdir=app/app [testenv] deps= diff --git a/tox.ini b/tox.ini index a14f591..9ae9118 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,7 @@ # content of: tox.ini , put in same dir as setup.py [tox] envlist = py36,flake8 +setupdir=app/app [testenv] deps= -- cgit 1.2.3-korg