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