rename xmlrpcprotocol into sfaprotocol
[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.sfaprotocol as sfaprotocol
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 %s"%(len(self.url2version),self.url2version.keys()))
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             retcod=os.unlink(self.filename)
67             logger.info("Cleaned up version cache %s, retcod=%d"%(self.filename,retcod))
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         logger.debug("searching in version cache %s"%self.url())
135         cached_version = VersionCache().get(self.url())
136         if cached_version is not None:
137             logger.info("Retrieved version info from cache")
138             return cached_version
139         ### otherwise let's do the hard work
140         # dummy to meet Sfi's expectations for its 'options' field
141         class DummyOptions:
142             pass
143         options=DummyOptions()
144         options.verbose=self.verbose
145         options.timeout=10
146         try:
147             client=Sfi(options)
148             client.read_config()
149             key_file = client.get_key_file()
150             cert_file = client.get_cert_file(key_file)
151             url=self.url()
152             logger.info('issuing GetVersion at %s'%url)
153             # setting timeout here seems to get the call to fail - even though the response time is fast
154             #server=sfaprotocol.server_proxy(url, key_file, cert_file, verbose=self.verbose, timeout=options.timeout)
155             server=sfaprotocol.server_proxy(url, key_file, cert_file, verbose=self.verbose)
156             self._version=server.GetVersion()
157         except:
158             logger.log_exc("failed to get version")
159             self._version={}
160         # so that next run from this process will find out
161         self.probed=True
162         # store in version cache so next processes will remember for an hour
163         cache=VersionCache()
164         cache.set(self.url(),self._version)
165         cache.save()
166         logger.debug("Saved version for url=%s in version cache"%self.url())
167         # that's our result
168         return self._version
169
170     @staticmethod
171     def multi_lines_label(*lines):
172         result='<<TABLE BORDER="0" CELLBORDER="0"><TR><TD>' + \
173             '</TD></TR><TR><TD>'.join(lines) + \
174             '</TD></TR></TABLE>>'
175         return result
176
177     # default is for when we can't determine the type of the service
178     # typically the server is down, or we can't authenticate, or it's too old code
179     shapes = {"registry": "diamond", "slicemgr":"ellipse", "aggregate":"box", 'default':'plaintext'}
180     abbrevs = {"registry": "REG", "slicemgr":"SA", "aggregate":"AM", 'default':'[unknown interface]'}
181
182     # return a dictionary that translates into the node's attr
183     def get_layout (self):
184         layout={}
185         ### retrieve cached GetVersion
186         version=self.get_version()
187         # set the href; xxx would make sense to try and 'guess' the web URL, not the API's one...
188         layout['href']=self.url()
189         ### set html-style label
190         ### see http://www.graphviz.org/doc/info/shapes.html#html
191         # if empty the service is unreachable
192         if not version:
193             label="offline"
194         else:
195             label=''
196             try: abbrev=Interface.abbrevs[version['interface']]
197             except: abbrev=Interface.abbrevs['default']
198             label += abbrev
199             if 'hrn' in version: label += " %s"%version['hrn']
200             else:                label += "[no hrn]"
201             if 'code_tag' in version: 
202                 label += " %s"%version['code_tag']
203             if 'testbed' in version:
204                 label += " (%s)"%version['testbed']
205         layout['label']=Interface.multi_lines_label(self.url(),label)
206         ### set shape
207         try: shape=Interface.shapes[version['interface']]
208         except: shape=Interface.shapes['default']
209         layout['shape']=shape
210         ### fill color to outline wrongly configured bodies
211         if 'geni_api' not in version and 'sfa' not in version:
212             layout['style']='filled'
213             layout['fillcolor']='gray'
214         return layout
215
216 class SfaScan:
217
218     # provide the entry points (a list of interfaces)
219     def __init__ (self, left_to_right=False, verbose=False):
220         self.verbose=verbose
221         self.left_to_right=left_to_right
222
223     def graph (self,entry_points):
224         graph=pygraphviz.AGraph(directed=True)
225         if self.left_to_right: 
226             graph.graph_attr['rankdir']='LR'
227         self.scan(entry_points,graph)
228         return graph
229     
230     # scan from the given interfaces as entry points
231     def scan(self,interfaces,graph):
232         if not isinstance(interfaces,list):
233             interfaces=[interfaces]
234
235         # remember node to interface mapping
236         node2interface={}
237         # add entry points right away using the interface uid's as a key
238         to_scan=interfaces
239         for i in interfaces: 
240             graph.add_node(i.uid())
241             node2interface[graph.get_node(i.uid())]=i
242         scanned=[]
243         # keep on looping until we reach a fixed point
244         # don't worry about abels and shapes that will get fixed later on
245         while to_scan:
246             for interface in to_scan:
247                 # performing xmlrpc call
248                 logger.info("retrieving/fetching version at interface %s"%interface.url())
249                 version=interface.get_version()
250                 if not version:
251                     logger.info("<EMPTY GetVersion(); offline or cannot authenticate>")
252                 else: 
253                     for (k,v) in version.iteritems(): 
254                         if not isinstance(v,dict):
255                             logger.debug("\r\t%s:%s"%(k,v))
256                         else:
257                             logger.debug(k)
258                             for (k1,v1) in v.iteritems():
259                                 logger.debug("\r\t\t%s:%s"%(k1,v1))
260                 # 'geni_api' is expected if the call succeeded at all
261                 # 'peers' is needed as well as AMs typically don't have peers
262                 if 'geni_api' in version and 'peers' in version: 
263                     # proceed with neighbours
264                     for (next_name,next_url) in version['peers'].iteritems():
265                         next_interface=Interface(next_url)
266                         # locate or create node in graph
267                         try:
268                             # if found, we're good with this one
269                             next_node=graph.get_node(next_interface.uid())
270                         except:
271                             # otherwise, let's move on with it
272                             graph.add_node(next_interface.uid())
273                             next_node=graph.get_node(next_interface.uid())
274                             node2interface[next_node]=next_interface
275                             to_scan.append(next_interface)
276                         graph.add_edge(interface.uid(),next_interface.uid())
277                 scanned.append(interface)
278                 to_scan.remove(interface)
279             # we've scanned the whole graph, let's get the labels and shapes right
280             for node in graph.nodes():
281                 interface=node2interface.get(node,None)
282                 if interface:
283                     for (k,v) in interface.get_layout().iteritems():
284                         node.attr[k]=v
285                 else:
286                     logger.error("MISSED interface with node %s"%node)
287     
288
289 default_outfiles=['sfa.png','sfa.svg','sfa.dot']
290
291 def main():
292     usage="%prog [options] url-entry-point(s)"
293     parser=OptionParser(usage=usage)
294     parser.add_option("-o","--output",action='append',dest='outfiles',default=[],
295                       help="output filenames (cumulative) - defaults are %r"%default_outfiles)
296     parser.add_option("-l","--left-to-right",action="store_true",dest="left_to_right",default=False,
297                       help="instead of top-to-bottom")
298     parser.add_option("-v", "--verbose", action="count", dest="verbose", default=0,
299                       help="verbose - can be repeated for more verbosity")
300     parser.add_option("-c", "--clean-cache",action='store_true',
301                       dest='clean_cache',default=False,
302                       help='clean/trash version cache and exit')
303     parser.add_option("-s","--show-cache",action='store_true',
304                       dest='show_cache',default=False,
305                       help='show/display version cache')
306     
307     (options,args)=parser.parse_args()
308     logger.enable_console()
309     # apply current verbosity to logger
310     logger.setLevelFromOptVerbose(options.verbose)
311     # figure if we need to be verbose for these local classes that only have a bool flag
312     bool_verbose=logger.getBoolVerboseFromOpt(options.verbose)
313
314     if options.show_cache: 
315         VersionCache().show()
316         sys.exit(0)
317     if options.clean_cache:
318         VersionCache().clean()
319         sys.exit(0)
320     if not args:
321         parser.print_help()
322         sys.exit(1)
323         
324     if not options.outfiles:
325         options.outfiles=default_outfiles
326     scanner=SfaScan(left_to_right=options.left_to_right, verbose=bool_verbose)
327     entries = [ Interface(entry) for entry in args ]
328     g=scanner.graph(entries)
329     logger.info("creating layout")
330     g.layout(prog='dot')
331     for outfile in options.outfiles:
332         logger.info("drawing in %s"%outfile)
333         g.draw(outfile)
334     logger.info("done")
335
336 if __name__ == '__main__':
337     main()