python3 - 2to3 + miscell obvious tweaks
[sfa.git] / sfa / client / sfascan.py
1
2
3 import sys
4 import os.path
5 import pickle
6 import time
7 import socket
8 import traceback
9 from urllib.parse import urlparse
10
11 try:
12     import pygraphviz
13 except:
14     print('Warning, could not import pygraphviz, test mode only')
15
16 from optparse import OptionParser
17
18 from sfa.client.return_value import ReturnValue
19 from sfa.client.sfi import Sfi
20 from sfa.util.sfalogging import logger, DEBUG
21 from sfa.client.sfaserverproxy import SfaServerProxy
22
23
24 def url_hostname_port(url):
25     if url.find("://") < 0:
26         url = "http://" + url
27     parsed_url = urlparse(url)
28     # 0(scheme) returns protocol
29     default_port = '80'
30     if parsed_url[0] == 'https':
31         default_port = '443'
32     # 1(netloc) returns the hostname+port part
33     parts = parsed_url[1].split(":")
34     # just a hostname
35     if len(parts) == 1:
36         return (url, parts[0], default_port)
37     else:
38         return (url, parts[0], parts[1])
39
40 # a very simple cache mechanism so that successive runs (see make)
41 # will go *much* faster
42 # assuming everything is sequential, as simple as it gets
43 # { url -> (timestamp, version)}
44
45
46 class VersionCache:
47     # default expiration period is 1h
48
49     def __init__(self, filename=None, expires=60 * 60):
50         # default is to store cache in the same dir as argv[0]
51         if filename is None:
52             filename = os.path.join(os.path.dirname(
53                 sys.argv[0]), "sfascan-version-cache.pickle")
54         self.filename = filename
55         self.expires = expires
56         self.url2version = {}
57         self.load()
58
59     def load(self):
60         try:
61             infile = open(self.filename, 'r')
62             self.url2version = pickle.load(infile)
63             infile.close()
64         except:
65             logger.debug("Cannot load version cache, restarting from scratch")
66             self.url2version = {}
67         logger.debug("loaded version cache with %d entries %s" % (len(self.url2version),
68                                                                   list(self.url2version.keys())))
69
70     def save(self):
71         try:
72             outfile = open(self.filename, 'w')
73             pickle.dump(self.url2version, outfile)
74             outfile.close()
75         except:
76             logger.log_exc("Cannot save version cache into %s" % self.filename)
77
78     def clean(self):
79         try:
80             retcod = os.unlink(self.filename)
81             logger.info("Cleaned up version cache %s, retcod=%d" %
82                         (self.filename, retcod))
83         except:
84             logger.info("Could not unlink version cache %s" % self.filename)
85
86     def show(self):
87         entries = len(self.url2version)
88         print("version cache from file %s has %d entries" %
89               (self.filename, entries))
90         key_values = list(self.url2version.items())
91
92         def old_first(kv1, kv2): return int(kv1[1][0] - kv2[1][0])
93         key_values.sort(old_first)
94         for key_value in key_values:
95             (url, tuple) = key_value
96             (timestamp, version) = tuple
97             how_old = time.time() - timestamp
98             if how_old <= self.expires:
99                 print(url, "-- %d seconds ago" % how_old)
100             else:
101                 print("OUTDATED", url, "(%d seconds ago, expires=%d)" %
102                       (how_old, self.expires))
103
104     # turns out we might have trailing slashes or not
105     def normalize(self, url):
106         return url.strip("/")
107
108     def set(self, url, version):
109         url = self.normalize(url)
110         self.url2version[url] = (time.time(), version)
111
112     def get(self, url):
113         url = self.normalize(url)
114         try:
115             (timestamp, version) = self.url2version[url]
116             how_old = time.time() - timestamp
117             if how_old <= self.expires:
118                 return version
119             else:
120                 return None
121         except:
122             return None
123
124 ###
125 # non-existing hostnames happen...
126 # for better perfs we cache the result of gethostbyname too
127
128
129 class Interface:
130
131     def __init__(self, url, mentioned_in=None, verbose=False):
132         self._url = url
133         self.verbose = verbose
134         cache = VersionCache()
135         key = "interface:%s" % url
136         try:
137             (self._url, self.hostname, self.port) = url_hostname_port(url)
138             # look for ip in the cache
139             tuple = cache.get(key)
140             if tuple:
141                 (self.hostname, self.ip, self.port) = tuple
142             else:
143                 self.ip = socket.gethostbyname(self.hostname)
144         except:
145             msg = "can't resolve hostname %s\n\tfound in url %s" % (
146                 self.hostname, self._url)
147             if mentioned_in:
148                 msg += "\n\t(mentioned at %s)" % mentioned_in
149             logger.warning(msg)
150             self.hostname = "unknown"
151             self.ip = '0.0.0.0'
152             self.port = "???"
153
154         cache.set(key, (self.hostname, self.ip, self.port,))
155         cache.save()
156         self.probed = False
157
158         # mark unknown interfaces as probed to avoid unnecessary attempts
159         if self.hostname == 'unknown':
160             # don't really try it
161             self.probed = True
162             self._version = {}
163
164     def url(self):
165         return self._url
166
167     # this is used as a key for creating graph nodes and to avoid duplicates
168     def uid(self):
169         return "%s:%s" % (self.ip, self.port)
170
171     # connect to server and trigger GetVersion
172     def get_version(self):
173         # if we already know the answer:
174         if self.probed:
175             return self._version
176         # otherwise let's look in the cache file
177         logger.debug("searching in version cache %s" % self.url())
178         cached_version = VersionCache().get(self.url())
179         if cached_version is not None:
180             logger.info("Retrieved version info from cache %s" % self.url())
181             return cached_version
182         # otherwise let's do the hard work
183         # dummy to meet Sfi's expectations for its 'options' field
184
185         class DummyOptions:
186             pass
187         options = DummyOptions()
188         options.verbose = self.verbose
189         options.timeout = 10
190         try:
191             client = Sfi(options)
192             client.read_config()
193             client.bootstrap()
194             key_file = client.private_key
195             cert_file = client.my_gid
196             logger.debug("using key %s & cert %s" % (key_file, cert_file))
197             url = self.url()
198             logger.info('issuing GetVersion at %s' % url)
199             # setting timeout here seems to get the call to fail - even though the response time is fast
200             #server=SfaServerProxy(url, key_file, cert_file, verbose=self.verbose, timeout=options.timeout)
201             server = SfaServerProxy(
202                 url, key_file, cert_file, verbose=self.verbose)
203             self._version = ReturnValue.get_value(server.GetVersion())
204         except:
205             logger.log_exc("failed to get version")
206             self._version = {}
207         # so that next run from this process will find out
208         self.probed = True
209         # store in version cache so next processes will remember for an hour
210         cache = VersionCache()
211         cache.set(self.url(), self._version)
212         cache.save()
213         logger.debug("Saved version for url=%s in version cache" % self.url())
214         # that's our result
215         return self._version
216
217     @staticmethod
218     def multi_lines_label(*lines):
219         result = '<<TABLE BORDER="0" CELLBORDER="0"><TR><TD>' + \
220             '</TD></TR><TR><TD>'.join(lines) + \
221             '</TD></TR></TABLE>>'
222         return result
223
224     # default is for when we can't determine the type of the service
225     # typically the server is down, or we can't authenticate, or it's too old
226     # code
227     shapes = {"registry": "diamond",
228               "aggregate": "box", 'default': 'plaintext'}
229     abbrevs = {"registry": "REG",
230                "aggregate": "AM", 'default': '[unknown interface]'}
231
232     # return a dictionary that translates into the node's attr
233     def get_layout(self):
234         layout = {}
235         # retrieve cached GetVersion
236         version = self.get_version()
237         # set the href; xxx would make sense to try and 'guess' the web URL,
238         # not the API's one...
239         layout['href'] = self.url()
240         # set html-style label
241         # see http://www.graphviz.org/doc/info/shapes.html#html
242         # if empty the service is unreachable
243         if not version:
244             label = "offline"
245         else:
246             label = ''
247             try:
248                 abbrev = Interface.abbrevs[version['interface']]
249             except:
250                 abbrev = Interface.abbrevs['default']
251             label += abbrev
252             if 'hrn' in version:
253                 label += " %s" % version['hrn']
254             else:
255                 label += "[no hrn]"
256             if 'code_tag' in version:
257                 label += " %s" % version['code_tag']
258             if 'testbed' in version:
259                 label += " (%s)" % version['testbed']
260         layout['label'] = Interface.multi_lines_label(self.url(), label)
261         # set shape
262         try:
263             shape = Interface.shapes[version['interface']]
264         except:
265             shape = Interface.shapes['default']
266         layout['shape'] = shape
267         # fill color to outline wrongly configured or unreachable bodies
268         # as of sfa-2.0 registry doesn't have 'sfa' not 'geni_api',
269         # but have peer aggregates have 'geni_api' and 'sfa'
270         if 'geni_api' not in version and 'peers' not in version:
271             layout['style'] = 'filled'
272             layout['fillcolor'] = 'gray'
273         return layout
274
275
276 class Scanner:
277
278     # provide the entry points (a list of interfaces)
279     def __init__(self, left_to_right=False, verbose=False):
280         self.verbose = verbose
281         self.left_to_right = left_to_right
282
283     def graph(self, entry_points):
284         graph = pygraphviz.AGraph(directed=True)
285         if self.left_to_right:
286             graph.graph_attr['rankdir'] = 'LR'
287         self.scan(entry_points, graph)
288         return graph
289
290     # scan from the given interfaces as entry points
291     def scan(self, interfaces, graph):
292         if not isinstance(interfaces, list):
293             interfaces = [interfaces]
294
295         # remember node to interface mapping
296         node2interface = {}
297         # add entry points right away using the interface uid's as a key
298         to_scan = interfaces
299         for i in interfaces:
300             graph.add_node(i.uid())
301             node2interface[graph.get_node(i.uid())] = i
302         scanned = []
303         # keep on looping until we reach a fixed point
304         # don't worry about abels and shapes that will get fixed later on
305         while to_scan:
306             for interface in to_scan:
307                 # performing xmlrpc call
308                 logger.info(
309                     "retrieving/fetching version at interface %s" % interface.url())
310                 version = interface.get_version()
311                 if not version:
312                     logger.info(
313                         "<EMPTY GetVersion(); offline or cannot authenticate>")
314                 else:
315                     for (k, v) in version.items():
316                         if not isinstance(v, dict):
317                             logger.debug("\r\t%s:%s" % (k, v))
318                         else:
319                             logger.debug(k)
320                             for (k1, v1) in v.items():
321                                 logger.debug("\r\t\t%s:%s" % (k1, v1))
322                 # proceed with neighbours
323                 if 'peers' in version:
324                     for (next_name, next_url) in version['peers'].items():
325                         next_interface = Interface(
326                             next_url, mentioned_in=interface.url())
327                         # locate or create node in graph
328                         try:
329                             # if found, we're good with this one
330                             next_node = graph.get_node(next_interface.uid())
331                         except:
332                             # otherwise, let's move on with it
333                             graph.add_node(next_interface.uid())
334                             next_node = graph.get_node(next_interface.uid())
335                             node2interface[next_node] = next_interface
336                             to_scan.append(next_interface)
337                         graph.add_edge(interface.uid(), next_interface.uid())
338                 scanned.append(interface)
339                 to_scan.remove(interface)
340             # we've scanned the whole graph, let's get the labels and shapes
341             # right
342             for node in graph.nodes():
343                 interface = node2interface.get(node, None)
344                 if interface:
345                     for (k, v) in interface.get_layout().items():
346                         node.attr[k] = v
347                 else:
348                     logger.error("MISSED interface with node %s" % node)
349
350
351 class SfaScan:
352
353     default_outfiles = ['sfa.png', 'sfa.svg', 'sfa.dot']
354
355     def main(self):
356         usage = "%prog [options] url-entry-point(s)"
357         parser = OptionParser(usage=usage)
358         parser.add_option("-d", "--dir", dest="sfi_dir",
359                           help="config & working directory - default is " + Sfi.default_sfi_dir(),
360                           metavar="PATH", default=Sfi.default_sfi_dir())
361         parser.add_option("-o", "--output", action='append', dest='outfiles', default=[],
362                           help="output filenames (cumulative) - defaults are %r" % SfaScan.default_outfiles)
363         parser.add_option("-l", "--left-to-right", action="store_true", dest="left_to_right", default=False,
364                           help="instead of top-to-bottom")
365         parser.add_option("-v", "--verbose", action="count", dest="verbose", default=0,
366                           help="verbose - can be repeated for more verbosity")
367         parser.add_option("-c", "--clean-cache", action='store_true',
368                           dest='clean_cache', default=False,
369                           help='clean/trash version cache and exit')
370         parser.add_option("-s", "--show-cache", action='store_true',
371                           dest='show_cache', default=False,
372                           help='show/display version cache')
373
374         (options, args) = parser.parse_args()
375         logger.enable_console()
376         # apply current verbosity to logger
377         logger.setLevelFromOptVerbose(options.verbose)
378         # figure if we need to be verbose for these local classes that only
379         # have a bool flag
380         bool_verbose = logger.getBoolVerboseFromOpt(options.verbose)
381
382         if options.show_cache:
383             VersionCache().show()
384             sys.exit(0)
385         if options.clean_cache:
386             VersionCache().clean()
387             sys.exit(0)
388         if not args:
389             parser.print_help()
390             sys.exit(1)
391
392         if not options.outfiles:
393             options.outfiles = SfaScan.default_outfiles
394         scanner = Scanner(left_to_right=options.left_to_right,
395                           verbose=bool_verbose)
396         entries = [Interface(entry, mentioned_in="command line")
397                    for entry in args]
398         try:
399             g = scanner.graph(entries)
400             logger.info("creating layout")
401             g.layout(prog='dot')
402             for outfile in options.outfiles:
403                 logger.info("drawing in %s" % outfile)
404                 g.draw(outfile)
405             logger.info("done")
406         # test mode when pygraphviz is not available
407         except:
408             entry = entries[0]
409             print("GetVersion at %s returned %s" %
410                   (entry.url(), entry.get_version()))