1# Copyright 2018 Datawire. All rights reserved.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License
14
15import logging
16import re
17from typing import Any, Dict, List, Optional, Tuple
18from typing import cast as typecast
19
20from ..envoy import EnvoyConfig
21from ..ir import IR
22from ..ir.irbasemappinggroup import IRBaseMappingGroup
23from ..ir.irhttpmappinggroup import IRHTTPMappingGroup
24from ..utils import dump_json
25from .envoy_stats import EnvoyStats
26
27
28class DiagSource(dict):
29 pass
30
31
32class DiagCluster(dict):
33 """
34 A DiagCluster represents what Envoy thinks about the health of a cluster.
35 DO NOT JUST PASS AN IRCluster into DiagCluster; turn it into a dict with
36 .as_dict() first.
37 """
38
39 def __init__(self, cluster) -> None:
40 super().__init__(**cluster)
41
42 def update_health(self, other: dict) -> None:
43 for from_key, to_key in [
44 ("health", "_health"),
45 ("hmetric", "_hmetric"),
46 ("hcolor", "_hcolor"),
47 ]:
48 if from_key in other:
49 self[to_key] = other[from_key]
50
51 def default_missing(self) -> dict:
52 for key, default in [
53 ("service", "unknown service!"),
54 ("weight", 100),
55 ("_hmetric", "unknown"),
56 ("_hcolor", "orange"),
57 ]:
58 if not self.get(key, None):
59 self[key] = default
60
61 return dict(self)
62
63 @classmethod
64 def unknown_cluster(cls):
65 return DiagCluster(
66 {
67 "service": "unknown service!",
68 "_health": "unknown cluster!",
69 "_hmetric": "unknown",
70 "_hcolor": "orange",
71 }
72 )
73
74
75class DiagClusters:
76 """
77 DiagClusters is, unsuprisingly, a set of DiagCluster. The thing about DiagClusters
78 is that the [] operator always gives you a valid DiagCluster -- it'll use DiagCluster.unknown()
79 to make a new DiagCluster if you ask for one that doesn't exist.
80 """
81
82 clusters: Dict[str, DiagCluster]
83
84 def __init__(self, clusters: Optional[List[dict]] = None) -> None:
85 self.clusters = {}
86
87 if clusters:
88 for cluster in typecast(List[dict], clusters):
89 self[cluster["name"]] = DiagCluster(cluster)
90
91 def __getitem__(self, key: str) -> DiagCluster:
92 if key not in self.clusters:
93 self.clusters[key] = DiagCluster.unknown_cluster()
94
95 return self.clusters[key]
96
97 def __setitem__(self, key: str, value: DiagCluster) -> None:
98 self.clusters[key] = value
99
100 def __contains__(self, key: str) -> bool:
101 return key in self.clusters
102
103 def as_json(self):
104 return dump_json(self.clusters, pretty=True)
105
106
107class DiagResult:
108 """
109 A DiagResult is the result of a diagnostics request, whether for an
110 overview or for a particular key.
111 """
112
113 def __init__(self, diag: "Diagnostics", estat: EnvoyStats, request) -> None:
114 self.diag = diag
115 self.logger = self.diag.logger
116 self.estat = estat
117
118 # Go ahead and grab Envoy cluster stats for all possible clusters.
119 # XXX This might be a bit silly.
120 self.cstats = {
121 cluster.name: self.estat.cluster_stats(cluster.stats_name)
122 for cluster in self.diag.clusters.values()
123 }
124
125 # Save the request host and scheme. We'll need them later.
126 self.request_host = request.headers.get("Host", "*")
127 self.request_scheme = request.headers.get("X-Forwarded-Proto", "http").lower()
128
129 # All of these things reflect _only_ resources that are relevant to the request
130 # we're handling -- e.g. if you ask for a particular group, you'll only get the
131 # clusters that are part of that group.
132 self.clusters: Dict[str, DiagCluster] = {} # Envoy clusters
133 self.routes: List[dict] = [] # Envoy routes
134 self.element_keys: Dict[str, bool] = {} # Active element keys
135 self.ambassador_resources: Dict[
136 str, str
137 ] = {} # Actually serializations of Ambassador config resources
138 self.envoy_resources: Dict[str, dict] = {} # Envoy config resources
139
140 def as_dict(self) -> Dict[str, Any]:
141 return {
142 "cluster_stats": self.cstats,
143 "cluster_info": self.clusters,
144 "route_info": self.routes,
145 "active_elements": sorted(self.element_keys.keys()),
146 "ambassador_resources": self.ambassador_resources,
147 "envoy_resources": self.envoy_resources,
148 }
149
150 def include_element(self, key: str) -> None:
151 """
152 Note that a particular key is something relevant to this result -- e.g.
153 'oh, the key foo-mapping.1 is active here'.
154
155 One problem here is that we don't currently cycle over to make sure that
156 all the requisite higher-level objects are brought in when we mark an
157 element active. This needs fixing.
158
159 :param key: the key we want to remember as being active.
160 """
161 self.element_keys[key] = True
162
163 def include_referenced_elements(self, obj: dict) -> None:
164 """
165 Include all of the elements in the given object's _referenced_by
166 array.
167
168 :param obj: object for which to include referencing keys
169 """
170
171 for element_key in obj["_referenced_by"]:
172 self.include_element(element_key)
173
174 def include_cluster(self, cluster: dict) -> DiagCluster:
175 """
176 Note that a particular cluster and everything that references it are
177 relevant to this result. If the cluster has related health information in
178 our cstats, fold that in too.
179
180 Don't pass an IRCluster here -- turn it into a dict with as_dict()
181 first.
182
183 Returns the DiagCluster that we actually use to hold everything.
184
185 :param cluster: dictionary version of a cluster to mark as active.
186 :return: the DiagCluster for this cluster
187 """
188
189 c_name = cluster["name"]
190
191 if c_name not in self.clusters:
192 self.clusters[c_name] = DiagCluster(cluster)
193
194 if c_name in self.cstats:
195 self.clusters[c_name].update_health(self.cstats[c_name])
196
197 self.include_referenced_elements(cluster)
198
199 return self.clusters[c_name]
200
201 def include_httpgroup(self, group: IRHTTPMappingGroup) -> None:
202 """
203 Note that a particular IRHTTPMappingGroup, all of the clusters it uses for upstream
204 traffic, and everything that references it are relevant to this result.
205
206 This method actually does a fair amount of work around handling clusters, shadow
207 clusters, and host_redirects. It would be a horrible mistake to duplicate this
208 elsewhere.
209
210 :param group: IRHTTPMappingGroup to include
211 """
212
213 # self.logger.debug("GROUP %s" % group.as_json())
214
215 prefix = group["prefix"] if "prefix" in group else group["regex"]
216 rewrite = group.get("rewrite", "/")
217 method = "*"
218 host = None
219
220 route_clusters: List[DiagCluster] = []
221
222 for mapping in group.get("mappings", []):
223 cluster = mapping["cluster"]
224
225 mapping_cluster = self.include_cluster(cluster.as_dict())
226 mapping_cluster.update({"weight": mapping.get("weight", 100)})
227
228 # self.logger.debug("GROUP %s CLUSTER %s %d%% (%s)" %
229 # (group['group_id'], c_name, mapping['weight'], mapping_cluster))
230
231 route_clusters.append(mapping_cluster)
232
233 host_redir = group.get("host_redirect", None)
234
235 if host_redir:
236 # XXX Stupid hackery here. redirect_cluster should be a real
237 # IRCluster object.
238 redirect_cluster = self.include_cluster(
239 {
240 "name": host_redir["name"],
241 "service": host_redir["service"],
242 "weight": 100,
243 "type_label": "redirect",
244 "_referenced_by": [host_redir["rkey"]],
245 }
246 )
247
248 route_clusters.append(redirect_cluster)
249
250 self.logger.debug("host_redirect route: %s" % group)
251 self.logger.debug("host_redirect cluster: %s" % redirect_cluster)
252
253 shadows = group.get("shadows", [])
254
255 for shadow in shadows:
256 # Shadows have a real cluster object.
257 shadow_dict = shadow["cluster"].as_dict()
258 shadow_dict["type_label"] = "shadow"
259
260 shadow_cluster = self.include_cluster(shadow_dict)
261 route_clusters.append(shadow_cluster)
262
263 self.logger.debug("shadow route: %s" % group)
264 self.logger.debug("shadow cluster: %s" % shadow_cluster)
265
266 headers = []
267
268 for header in group.get("headers", []):
269 hdr_name = header.get("name", None)
270 hdr_value = header.get("value", None)
271
272 if hdr_name == ":authority":
273 host = hdr_value
274 elif hdr_name == ":method":
275 method = hdr_value
276 else:
277 headers.append(header)
278
279 sep = "" if prefix.startswith("/") else "/"
280 route_key = "%s://%s%s%s" % (
281 self.request_scheme,
282 host if host else self.request_host,
283 sep,
284 prefix,
285 )
286
287 route_info = {
288 "_route": group.as_dict(),
289 "_source": group["location"],
290 "_group_id": group["group_id"],
291 "key": route_key,
292 "prefix": prefix,
293 "rewrite": rewrite,
294 "method": method,
295 "headers": headers,
296 "clusters": [x.default_missing() for x in route_clusters],
297 "host": host if host else "*",
298 }
299
300 if "precedence" in group:
301 route_info["precedence"] = group["precedence"]
302
303 metadata_labels = group.get("metadata_labels") or {}
304 diag_class = metadata_labels.get("ambassador_diag_class") or None
305
306 if diag_class:
307 route_info["diag_class"] = diag_class
308
309 self.routes.append(route_info)
310 self.include_referenced_elements(group)
311
312 def finalize(self) -> None:
313 """
314 Make sure that all the elements we've marked as included actually appear
315 in the ambassador_resources and envoy_resources dictionaries, so that the
316 UI can properly connect all the dots.
317 """
318
319 for key in self.element_keys.keys():
320 amb_el_info = self.diag.ambassador_elements.get(key, None)
321
322 if amb_el_info:
323 serialization = amb_el_info.get("serialization", None)
324
325 if serialization:
326 self.ambassador_resources[key] = serialization
327
328 # What about errors?
329
330 # Also make sure we have Envoy outputs for these things.
331 envoy_el_info = self.diag.envoy_elements.get(key, None)
332
333 if envoy_el_info:
334 self.envoy_resources[key] = envoy_el_info
335
336
337class Diagnostics:
338 """
339 Information needed by the Diagnostics UI. This has to be instantiated
340 from an IR and an EnvoyConfig (it doesn't matter which version).
341
342 The flow here is:
343
344 - create the Diagnostics object
345 - call the .overview method to get a DiagResult that has an overview of
346 the whole Ambassador setup, or
347 - call the .lookup method to get a DiagResult that zeroes in on a particular
348 chunk of the world (like a group, or a particular rkey, etc.)
349 """
350
351 ir: IR
352 econf: EnvoyConfig
353 estats: Optional[EnvoyStats]
354
355 source_map: Dict[str, Dict[str, bool]]
356
357 reKeyIndex = re.compile(r"\.(\d+)$")
358
359 filter_map = {"IRAuth": "AuthService", "IRRateLimit": "RateLimitService"}
360
361 def __init__(self, ir: IR, econf: EnvoyConfig) -> None:
362 self.logger = logging.getLogger("ambassador.diagnostics")
363 self.logger.debug("---- building diagnostics")
364
365 self.ir = ir
366 self.econf = econf
367 self.estats = None
368
369 # A fully-qualified key is e.g. "ambassador.yaml.1" -- source location plus
370 # object index. An unqualified key is something like "ambassador.yaml" -- no
371 # index.
372 #
373 # self.source_map permits us to look up any (potentially unqualified) key
374 # and find a list of fully-qualified keys contained in the key we looked
375 # up.
376 #
377 # self.ambassador_elements has the incoming Ambassador configuration resources,
378 # indexed by fully-qualified key.
379 #
380 # self.envoy_elements has the created Envoy configuration resources, indexed
381 # by fully-qualified key.
382
383 self.source_map: Dict[str, Dict[str, bool]] = {}
384 self.ambassador_elements: Dict[str, dict] = {}
385 self.envoy_elements: Dict[str, dict] = {}
386 self.ambassador_services: List[dict] = []
387 self.ambassador_resolvers: List[dict] = []
388
389 # Warn people about upcoming deprecations.
390
391 warn_auth = False
392 warn_ratelimit = False
393
394 for filter in self.ir.filters:
395 if filter.kind == "IRAuth":
396 proto = filter.get("proto") or "http"
397
398 if proto.lower() != "http":
399 warn_auth = True
400
401 if filter.kind == "IRRateLimit":
402 warn_ratelimit = True
403
404 things_to_warn = []
405
406 if warn_auth:
407 things_to_warn.append("AuthServices")
408
409 if warn_ratelimit:
410 things_to_warn.append("RateLimitServices")
411
412 if things_to_warn:
413 self.ir.aconf.post_notice(
414 f'A future Ambassador version will change the GRPC protocol version for {" and ".join(things_to_warn)}. See the CHANGELOG for details.'
415 )
416
417 # # Warn people about the default port change.
418 # if self.ir.ambassador_module.service_port < 1024:
419 # # Does it look like they explicitly asked for this?
420 # amod = self.ir.aconf.get_module('ambassador')
421 #
422 # if not (amod and amod.get('service_port')):
423 # # They did not explictly set the port. Warn them about the
424 # # port change.
425 # new_defaults = [ "port 8080 for HTTP" ]
426 #
427 # if self.ir.tls_contexts:
428 # new_defaults.append("port 8443 for HTTPS")
429 #
430 # default_ports = " and ".join(new_defaults)
431 #
432 # listen_ports = [ str(l.service_port) for l in self.ir.listeners ]
433 # self.ir.logger.info("listen_ports %s" % listen_ports)
434 #
435 # port_or_ports = "port" if (len(listen_ports) == 1) else "ports"
436 #
437 # last_port = listen_ports.pop()
438 #
439 # els = [ last_port ]
440 #
441 # if len(listen_ports) > 0:
442 # els.insert(0, ", ".join(listen_ports))
443 #
444 # port_nums = " and ".join(els)
445 #
446 # m1 = f'Ambassador 0.60 will default to listening on {default_ports}.'
447 # m2 = f'You will need to change your configuration to continue using {port_or_ports} {port_nums}.'
448 #
449 # self.ir.aconf.post_notice(f'{m1} {m2}')
450
451 # Copy in the toplevel 'error' and 'notice' sets.
452 self.errors = self.ir.aconf.errors
453 self.notices = self.ir.aconf.notices
454
455 # Next up, walk the list of Ambassador sources.
456 for key, rsrc in self.ir.aconf.sources.items():
457 uqkey = key # Unqualified key, e.g. ambassador.yaml
458 fqkey = uqkey # Fully-qualified key, e.g. ambassador.yaml.1
459
460 key_index = None
461
462 if "rkey" in rsrc:
463 uqkey, key_index = self.split_key(rsrc.rkey)
464
465 if key_index is not None:
466 fqkey = "%s.%s" % (uqkey, key_index)
467
468 location, _ = self.split_key(rsrc.get("location", key))
469
470 self.logger.debug(
471 " %s (%s): UQ %s, FQ %s, LOC %s" % (key, rsrc, uqkey, fqkey, location)
472 )
473
474 self.remember_source(uqkey, fqkey, location, rsrc.rkey)
475
476 ambassador_element: dict = self.ambassador_elements.setdefault(
477 fqkey, {"location": location, "kind": rsrc.kind}
478 )
479
480 if uqkey and (uqkey != fqkey):
481 ambassador_element["parent"] = uqkey
482
483 serialization = rsrc.get("serialization", None)
484 if serialization:
485 if ambassador_element["kind"] == "Secret":
486 serialization = "kind: Secret\ndata: (elided by Ambassador)\n"
487 ambassador_element["serialization"] = serialization
488
489 # Next up, the Envoy elements.
490 for kind, elements in self.econf.elements.items():
491 for fqkey, envoy_element in elements.items():
492 # The key here should already be fully qualified.
493 uqkey, _ = self.split_key(fqkey)
494
495 element_dict = self.envoy_elements.setdefault(fqkey, {})
496 element_list = element_dict.setdefault(kind, [])
497 element_list.append({k: v for k, v in envoy_element.items() if k[0] != "_"})
498
499 # Always generate the full group set so that we can look up groups.
500 self.groups = {
501 "grp-%s" % group.group_id: group
502 for group in self.ir.groups.values()
503 if group.location != "--diagnostics--"
504 }
505
506 # Always generate the full cluster set so that we can look up clusters.
507 self.clusters = {
508 cluster.name: cluster
509 for cluster in self.ir.clusters.values()
510 if cluster.location != "--diagnostics--"
511 }
512
513 # Build up our Ambassador services too (auth, ratelimit, tracing).
514 self.ambassador_services = []
515
516 for filt in self.ir.filters:
517 # self.logger.debug("FILTER %s" % filter.as_json())
518
519 if filt.kind in Diagnostics.filter_map:
520 type_name = Diagnostics.filter_map[filt.kind]
521 self.add_ambassador_service(filt, type_name)
522
523 if self.ir.tracing:
524 self.add_ambassador_service(
525 self.ir.tracing, "TracingService (%s)" % self.ir.tracing.driver
526 )
527
528 self.ambassador_resolvers = []
529 used_resolvers: Dict[str, List[str]] = {}
530
531 for group in self.groups.values():
532 for mapping in group.mappings:
533 resolver_name = mapping.resolver
534 group_list = used_resolvers.setdefault(resolver_name, [])
535 group_list.append(group.rkey)
536
537 for name, resolver in sorted(self.ir.resolvers.items()):
538 if name in used_resolvers:
539 self.add_ambassador_resolver(resolver, used_resolvers[name])
540
541 def add_ambassador_service(self, svc, type_name) -> None:
542 """
543 Remember information about a given Ambassador-wide service (Auth, RateLimit, Tracing).
544
545 :param svc: service record
546 :param type_name: what kind of thing is this?
547 """
548
549 cluster = svc.cluster
550 urls = cluster.urls
551
552 svc_weight = 100.0 / len(urls)
553
554 for url in urls:
555 self.ambassador_services.append(
556 {
557 "type": type_name,
558 "_source": svc.location,
559 "name": url,
560 "cluster": cluster.name,
561 "_service_weight": svc_weight,
562 }
563 )
564
565 def add_ambassador_resolver(self, resolver, group_list) -> None:
566 """
567 Remember information about a given Ambassador-wide resolver.
568
569 :param resolver: resolver record
570 :param group_list: list of groups that use this resolver
571 """
572
573 self.ambassador_resolvers.append(
574 {
575 "kind": resolver.kind,
576 "_source": resolver.location,
577 "name": resolver.name,
578 "groups": group_list,
579 }
580 )
581
582 @staticmethod
583 def split_key(key) -> Tuple[str, Optional[str]]:
584 """
585 Split a key into its components (the base name and the object index).
586
587 :param key: possibly-qualified key
588 :return: tuple of the base and a possible index
589 """
590
591 key_base = key
592 key_index = None
593
594 m = Diagnostics.reKeyIndex.search(key)
595
596 if m:
597 key_base = key[: m.start()]
598 key_index = m.group(1)
599
600 return key_base, key_index
601
602 def as_dict(self) -> dict:
603 return {
604 "source_map": self.source_map,
605 "ambassador_services": self.ambassador_services,
606 "ambassador_resolvers": self.ambassador_resolvers,
607 "ambassador_elements": self.ambassador_elements,
608 "envoy_elements": self.envoy_elements,
609 "errors": self.errors,
610 "notices": self.notices,
611 "groups": {key: self.flattened(value) for key, value in self.groups.items()},
612 # 'clusters': { key: value.as_dict() for key, value in self.clusters.items() },
613 "tlscontexts": [x.as_dict() for x in self.ir.tls_contexts.values()],
614 }
615
616 def flattened(self, group: IRBaseMappingGroup) -> dict:
617 flattened = {k: v for k, v in group.as_dict().items() if k != "mappings"}
618 flattened_mappings = []
619
620 for m in group["mappings"]:
621 fm = {
622 "_active": m["_active"],
623 "_errored": m["_errored"],
624 "_rkey": m["rkey"],
625 "location": m["location"],
626 "name": m["name"],
627 "cluster_service": m.get("cluster", {}).get("service"),
628 "cluster_name": m.get("cluster", {}).get("envoy_name"),
629 }
630
631 if flattened["kind"] == "IRHTTPMappingGroup":
632 fm["prefix"] = m.get("prefix")
633
634 rewrite = m.get("rewrite", None)
635
636 if rewrite:
637 fm["rewrite"] = rewrite
638
639 host = m.get("host", None)
640
641 if host:
642 fm["host"] = host
643
644 flattened_mappings.append(fm)
645
646 flattened["mappings"] = flattened_mappings
647
648 return flattened
649
650 def _remember_source(self, src_key: str, dest_key: str) -> None:
651 """
652 Link keys of active sources together. The source map lets us answer questions
653 like 'which objects does ambassador.yaml define?' and this is the primitive
654 that actually populates the map.
655
656 The src_key is where you start the lookup; the dest_key is something defined
657 by the src_key. They can be the same.
658
659 :param src_key: the starting key (ambassador.yaml)
660 :param dest_key: the destination key (ambassador.yaml.1)
661 """
662
663 src_map = self.source_map.setdefault(src_key, {})
664 src_map[dest_key] = True
665
666 def remember_source(
667 self, uqkey: str, fqkey: Optional[str], location: Optional[str], dest_key: str
668 ) -> None:
669 """
670 Populate the source map in various ways. A mapping from uqkey to dest_key is
671 always added; mappings for fqkey and location are added if they are unique
672 keys.
673
674 :param uqkey: unqualified source key
675 :param fqkey: qualified source key
676 :param location: source location
677 :param dest_key: key of object being defined
678 """
679 self._remember_source(uqkey, dest_key)
680
681 if fqkey and (fqkey != uqkey):
682 self._remember_source(fqkey, dest_key)
683
684 if location and (location != uqkey) and (location != fqkey):
685 self._remember_source(location, dest_key)
686
687 def overview(self, request, estat: EnvoyStats) -> Dict[str, Any]:
688 """
689 Generate overview data describing the whole Ambassador setup, most
690 notably the routing table. Returns the dictionary form of a DiagResult.
691
692 :param request: the Flask request being handled
693 :param estat: current EnvoyStats
694 :return: the dictionary form of a DiagResult
695 """
696
697 result = DiagResult(self, estat, request)
698
699 for group in self.ir.ordered_groups():
700 # TCPMappings are currently handled elsewhere.
701 if isinstance(group, IRHTTPMappingGroup):
702 result.include_httpgroup(group)
703
704 return result.as_dict()
705
706 def lookup(self, request, key: str, estat: EnvoyStats) -> Optional[Dict[str, Any]]:
707 """
708 Generate data describing a specific key in the Ambassador setup, and all
709 the things connected to it. Returns the dictionary form of a DiagResult.
710
711 'key' can be a group key that starts with grp-, a cluster key that starts
712 with cluster_, or a source key.
713
714 :param request: the Flask request being handled
715 :param key: the key of the thing we want
716 :param estat: current EnvoyStats
717 :return: the dictionary form of a DiagResult
718 """
719
720 result = DiagResult(self, estat, request)
721
722 # Typically we'll get handed a group identifier here, but we might get
723 # other stuff too, and we have to look for all of it.
724
725 found: bool = False
726
727 if key in self.groups:
728 # Yup, group ID.
729 group = self.groups[key]
730
731 # TCPMappings are currently handled elsewhere.
732 if isinstance(group, IRHTTPMappingGroup):
733 result.include_httpgroup(group)
734
735 found = True
736 elif key in self.clusters:
737 result.include_cluster(self.clusters[key].as_dict())
738 found = True
739 elif key in self.source_map:
740 # The source_map is set up like:
741 #
742 # "mapping-qotm.yaml": {
743 # "mapping-qotm.yaml.1": true,
744 # "mapping-qotm.yaml.2": true,
745 # "mapping-qotm.yaml.3": true
746 # }
747 #
748 # so for whatever we found, we need to tell the result to
749 # include every element in the keys of the dict stored for
750 # our key.
751 for subkey in self.source_map[key].keys():
752 result.include_element(subkey)
753 # Not a typo. Set found here, in case somehow we land on
754 # a key with no subkeys (which should be impossible, but,
755 # y'know).
756 found = True
757
758 if found:
759 result.finalize()
760 return result.as_dict()
761 else:
762 return None
View as plain text