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