...

Text file src/github.com/emissary-ingress/emissary/v3/python/ambassador/scout.py

Documentation: github.com/emissary-ingress/emissary/v3/python/ambassador

     1import errno
     2import json
     3import logging
     4import os
     5import platform
     6import sys
     7import traceback
     8from uuid import uuid4
     9
    10import requests
    11
    12
    13class Scout:
    14    def __init__(
    15        self,
    16        app,
    17        version,
    18        install_id=None,
    19        id_plugin=None,
    20        id_plugin_args={},
    21        scout_host="metriton.datawire.io",
    22        **kwargs
    23    ):
    24        """
    25        Create a new Scout instance for later reports.
    26
    27        :param app: The application name. Required.
    28        :param version: The application version. Required.
    29        :param install_id: Optional install_id. If set, Scout will believe it.
    30        :param id_plugin: Optional plugin function for obtaining an install_id. See below.
    31        :param id_plugin_args: Optional arguments to id_plugin. See below.
    32        :param kwargs: Any other keyword arguments will be merged into Scout's metadata.
    33
    34        If an id_plugin is present, it is called with the following parameters:
    35
    36        - this Scout instance
    37        - the passed-in app name
    38        - the passed-in id_plugin_args _as keyword arguments_
    39
    40        id_plugin(scout, app, **id_plugin_args)
    41
    42        It must return
    43
    44        - None to fall back to the default filesystem ID, or
    45        - a dict containing the ID and optional metadata:
    46           - The dict **must** have an `install_id` key with a non-empty value.
    47           - The dict **may** have other keys present, which will all be merged into
    48             Scout's `metadata`.
    49
    50        If the plugin returns something invalid, Scout falls back to the default filesystem
    51        ID.
    52
    53        See also Scout.configmap_install_id_plugin, which is an id_plugin that knows how
    54        to use a Kubernetes configmap (scout.config.$app) to store the install ID.
    55
    56        Scout logs to the datawire.scout logger. It assumes that the logging system is
    57        configured to a sane default level, but you can change Scout's debug level with e.g.
    58
    59        logging.getLogger("datawire.scout").setLevel(logging.DEBUG)
    60
    61        """
    62
    63        self.app = Scout.__not_blank("app", app)
    64        self.version = Scout.__not_blank("version", version)
    65        self.metadata = kwargs if kwargs is not None else {}
    66        self.user_agent = self.create_user_agent()
    67
    68        self.logger = logging.getLogger("datawire.scout")
    69
    70        self.install_id = install_id
    71
    72        if not self.install_id and id_plugin:
    73            plugin_response = id_plugin(self, app, **id_plugin_args)
    74
    75            self.logger.debug("Scout: id_plugin returns {0}".format(json.dumps(plugin_response)))
    76
    77            if plugin_response:
    78                if "install_id" in plugin_response:
    79                    self.install_id = plugin_response["install_id"]
    80                    del plugin_response["install_id"]
    81
    82                if plugin_response:
    83                    self.metadata = Scout.__merge_dicts(self.metadata, plugin_response)
    84
    85        if not self.install_id:
    86            self.install_id = self.__filesystem_install_id(app)
    87
    88        self.logger.debug("Scout using install_id {0}".format(self.install_id))
    89
    90        # scout options; controlled via env vars
    91        self.scout_host = os.getenv("SCOUT_HOST", scout_host)
    92        self.use_https = os.getenv("SCOUT_HTTPS", "1").lower() in {"1", "true", "yes"}
    93        self.disabled = Scout.__is_disabled()
    94
    95    def report(self, **kwargs):
    96        result = {"latest_version": self.version}
    97
    98        if self.disabled:
    99            return result
   100
   101        merged_metadata = Scout.__merge_dicts(self.metadata, kwargs)
   102
   103        headers = {"User-Agent": self.user_agent}
   104
   105        payload = {
   106            "application": self.app,
   107            "version": self.version,
   108            "install_id": self.install_id,
   109            "user_agent": self.create_user_agent(),
   110            "metadata": merged_metadata,
   111        }
   112
   113        self.logger.debug("Scout: report payload: %s" % json.dumps(payload, indent=4))
   114
   115        url = ("https://" if self.use_https else "http://") + "{}/scout".format(
   116            self.scout_host
   117        ).lower()
   118
   119        try:
   120            resp = requests.post(url, json=payload, headers=headers, timeout=1)
   121
   122            self.logger.debug("Scout: report returns %d (%s)" % (resp.status_code, resp.text))
   123
   124            if resp.status_code / 100 == 2:
   125                result = Scout.__merge_dicts(result, resp.json())
   126        except OSError as e:
   127            self.logger.warning("Scout: could not post report: %s" % e)
   128            result["exception"] = "could not post report: %s" % e
   129        except Exception as e:
   130            # If scout is down or we are getting errors just proceed as if nothing happened. It should not impact the
   131            # user at all.
   132            tb = "\n".join(traceback.format_exception(*sys.exc_info()))
   133
   134            result["exception"] = e
   135            result["traceback"] = tb
   136
   137        if "new_install" in self.metadata:
   138            del self.metadata["new_install"]
   139
   140        return result
   141
   142    def create_user_agent(self):
   143        result = "{0}/{1} ({2}; {3}; python {4})".format(
   144            self.app, self.version, platform.system(), platform.release(), platform.python_version()
   145        ).lower()
   146
   147        return result
   148
   149    def __filesystem_install_id(self, app):
   150        config_root = os.path.join(os.path.expanduser("~"), ".config", app)
   151        try:
   152            os.makedirs(config_root)
   153        except OSError as ex:
   154            if ex.errno == errno.EEXIST and os.path.isdir(config_root):
   155                pass
   156            else:
   157                raise
   158
   159        id_file = os.path.join(config_root, "id")
   160        if not os.path.isfile(id_file):
   161            with open(id_file, "w") as f:
   162                install_id = str(uuid4())
   163                self.metadata["new_install"] = True
   164                f.write(install_id)
   165        else:
   166            with open(id_file, "r") as f:
   167                install_id = f.read()
   168
   169        return install_id
   170
   171    @staticmethod
   172    def __not_blank(name, value):
   173        if value is None or str(value).strip() == "":
   174            raise ValueError("Value for '{}' is blank, empty or None".format(name))
   175
   176        return value
   177
   178    @staticmethod
   179    def __merge_dicts(x, y):
   180        z = x.copy()
   181        z.update(y)
   182        return z
   183
   184    @staticmethod
   185    def __is_disabled():
   186        if str(os.getenv("TRAVIS_REPO_SLUG")).startswith("datawire/"):
   187            return True
   188
   189        return os.getenv("SCOUT_DISABLE", "0").lower() in {"1", "true", "yes"}
   190
   191    @staticmethod
   192    def configmap_install_id_plugin(scout, app, map_name=None, namespace="default"):
   193        """
   194        Scout id_plugin that uses a Kubernetes configmap to store the install ID.
   195
   196        :param scout: Scout instance that's calling the plugin
   197        :param app: Name of the application that's using Scout
   198        :param map_name: Optional ConfigMap name to use; defaults to "scout.config.$app"
   199        :param namespace: Optional Kubernetes namespace to use; defaults to "default"
   200
   201        This plugin assumes that the KUBERNETES_SERVICE_{HOST,PORT,PORT_HTTPS}
   202        environment variables are set correctly, and it assumes the default Kubernetes
   203        namespace unless the 'namespace' keyword argument is used to select a different
   204        namespace.
   205
   206        If KUBERNETES_ACCESS_TOKEN is set in the environment, use that for the apiserver
   207        access token -- otherwise, the plugin assumes that it's running in a Kubernetes
   208        pod and tries to read its token from /var/run/secrets.
   209        """
   210
   211        plugin_response = None
   212
   213        if not map_name:
   214            map_name = "scout.config.{0}".format(app)
   215
   216        kube_host = os.environ.get("KUBERNETES_SERVICE_HOST", None)
   217
   218        try:
   219            kube_port = int(os.environ.get("KUBERNETES_SERVICE_PORT", 443))
   220        except ValueError:
   221            scout.logger.debug("Scout: KUBERNETES_SERVICE_PORT isn't numeric, defaulting to 443")
   222            kube_port = 443
   223
   224        kube_proto = "https" if (kube_port == 443) else "http"
   225
   226        kube_token = os.environ.get("KUBERNETES_ACCESS_TOKEN", None)
   227
   228        if not kube_host:
   229            # We're not running in Kubernetes. Fall back to the usual filesystem stuff.
   230            scout.logger.debug("Scout: no KUBERNETES_SERVICE_HOST, not running in Kubernetes")
   231            return None
   232
   233        if not kube_token:
   234            try:
   235                kube_token = open("/var/run/secrets/kubernetes.io/serviceaccount/token", "r").read()
   236            except OSError:
   237                pass
   238
   239        if not kube_token:
   240            # We're not running in Kubernetes. Fall back to the usual filesystem stuff.
   241            scout.logger.debug("Scout: not running in Kubernetes")
   242            return None
   243
   244        # OK, we're in a cluster. Load our map.
   245
   246        base_url = "%s://%s:%s" % (kube_proto, kube_host, kube_port)
   247        url_path = "api/v1/namespaces/%s/configmaps" % namespace
   248        auth_headers = {"Authorization": "Bearer " + kube_token}
   249        install_id = None
   250
   251        cm_url = "%s/%s" % (base_url, url_path)
   252        fetch_url = "%s/%s" % (cm_url, map_name)
   253
   254        scout.logger.debug("Scout: trying %s" % fetch_url)
   255
   256        try:
   257            r = requests.get(fetch_url, headers=auth_headers, verify=False)
   258
   259            if r.status_code == 200:
   260                # OK, the map is present. What do we see?
   261                map_data = r.json()
   262
   263                if "data" not in map_data:
   264                    # This is "impossible".
   265                    scout.logger.error("Scout: no map data in returned map???")
   266                else:
   267                    map_data = map_data.get("data", {})
   268                    scout.logger.debug("Scout: configmap has map data %s" % json.dumps(map_data))
   269
   270                    install_id = map_data.get("install_id", None)
   271
   272                    if install_id:
   273                        scout.logger.debug("Scout: got install_id %s from map" % install_id)
   274                        plugin_response = {"install_id": install_id}
   275        except OSError as e:
   276            scout.logger.debug(
   277                "Scout: could not read configmap (map %s, namespace %s): %s"
   278                % (map_name, namespace, e)
   279            )
   280
   281        if not install_id:
   282            # No extant install_id. Try to create a new one.
   283            install_id = str(uuid4())
   284
   285            cm = {
   286                "apiVersion": "v1",
   287                "kind": "ConfigMap",
   288                "metadata": {
   289                    "name": map_name,
   290                    "namespace": namespace,
   291                },
   292                "data": {"install_id": install_id},
   293            }
   294
   295            scout.logger.debug("Scout: saving new install_id %s" % install_id)
   296
   297            saved = False
   298            try:
   299                r = requests.post(cm_url, headers=auth_headers, verify=False, json=cm)
   300
   301                if r.status_code == 201:
   302                    saved = True
   303                    scout.logger.debug("Scout: saved install_id %s" % install_id)
   304
   305                    plugin_response = {"install_id": install_id, "new_install": True}
   306                else:
   307                    scout.logger.error(
   308                        "Scout: could not save install_id: {0}, {1}".format(r.status_code, r.text)
   309                    )
   310            except OSError as e:
   311                logging.debug(
   312                    "Scout: could not write configmap (map %s, namespace %s): %s"
   313                    % (map_name, namespace, e)
   314                )
   315
   316        scout.logger.debug("Scout: plugin_response %s" % json.dumps(plugin_response))
   317        return plugin_response

View as plain text