aboutsummaryrefslogtreecommitdiffstats
path: root/config_binding_service/client.py
blob: 51ce3b15d72366a66f90e99b1dce73c06d7b008c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
# ============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"]
        new_res[key] = json.loads(base64.b64decode(
            res["KV"]["Value"]).decode("utf-8"))

    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 <<f1,f2>> 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"))