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