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