add a version cache to sfascan
[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.info("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.log_exc ("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         for (url,tuple) in self.url2version.iteritems():
75             (timestamp,version) = tuple
76             how_old = time.time()-timestamp
77             if how_old<=self.expires:
78                 print url,"(%d seconds ago)"%how_old,"-> keys=",version.keys()
79             else:
80                 print url,"(%d seconds ago)"%how_old,"too old"
81     
82     def set (self,url,version):
83         self.url2version[url]=( time.time(), version)
84     def get (self,url):
85         try:
86             (timestamp,version)=self.url2version[url]
87             how_old = time.time()-timestamp
88             if how_old<=self.expires: return version
89             else: return None
90         except:
91             return None
92
93 ###
94 class Interface:
95
96     def __init__ (self,url,verbose=False):
97         self._url=url
98         self.verbose=verbose
99         try:
100             (self._url,self.hostname,self.port)=url_hostname_port(url)
101             self.ip=socket.gethostbyname(self.hostname)
102             self.probed=False
103         except:
104             self.hostname="unknown"
105             self.ip='0.0.0.0'
106             self.port="???"
107             # don't really try it
108             self.probed=True
109             self._version={}
110
111     def url(self):
112         return self._url
113
114     # this is used as a key for creating graph nodes and to avoid duplicates
115     def uid (self):
116         return "%s:%s"%(self.ip,self.port)
117
118     # connect to server and trigger GetVersion
119     def get_version(self):
120         ### if we already know the answer:
121         if self.probed:
122             return self._version
123         ### otherwise let's look in the cache file
124         cached_version = VersionCache().get(self.url())
125         if cached_version:
126             logger.info("Retrieved version info from cache")
127             return cached_version
128         ### otherwise let's do the hard work
129         # dummy to meet Sfi's expectations for its 'options' field
130         class DummyOptions:
131             pass
132         options=DummyOptions()
133         options.verbose=self.verbose
134         options.timeout=10
135         try:
136             client=Sfi(options)
137             client.read_config()
138             key_file = client.get_key_file()
139             cert_file = client.get_cert_file(key_file)
140             url=self.url()
141             logger.info('issuing GetVersion at %s'%url)
142             # setting timeout here seems to get the call to fail - even though the response time is fast
143             #server=xmlrpcprotocol.server_proxy(url, key_file, cert_file, verbose=self.verbose, timeout=options.timeout)
144             server=xmlrpcprotocol.server_proxy(url, key_file, cert_file, verbose=self.verbose)
145             self._version=server.GetVersion()
146         except:
147             logger.log_exc("failed to get version")
148             self._version={}
149         self.probed=True
150         cache=VersionCache()
151         cache.set(self.url(),self._version)
152         cache.save()
153         logger.info("Saved version for url=%s in version cache"%self.url())
154         return self._version
155
156     @staticmethod
157     def multi_lines_label(*lines):
158         result='<<TABLE BORDER="0" CELLBORDER="0"><TR><TD>' + \
159             '</TD></TR><TR><TD>'.join(lines) + \
160             '</TD></TR></TABLE>>'
161         return result
162
163     # default is for when we can't determine the type of the service
164     # typically the server is down, or we can't authenticate, or it's too old code
165     shapes = {"registry": "diamond", "slicemgr":"ellipse", "aggregate":"box", 'default':'plaintext'}
166     abbrevs = {"registry": "REG", "slicemgr":"SA", "aggregate":"AM", 'default':'[unknown interface]'}
167
168     # return a dictionary that translates into the node's attr
169     def get_layout (self):
170         layout={}
171         ### retrieve cached GetVersion
172         version=self.get_version()
173         # set the href; xxx would make sense to try and 'guess' the web URL, not the API's one...
174         layout['href']=self.url()
175         ### set html-style label
176         ### see http://www.graphviz.org/doc/info/shapes.html#html
177         # if empty the service is unreachable
178         if not version:
179             label="offline"
180         else:
181             label=''
182             try: abbrev=Interface.abbrevs[version['interface']]
183             except: abbrev=Interface.abbrevs['default']
184             label += abbrev
185             if 'hrn' in version: label += " %s"%version['hrn']
186             else:                label += "[no hrn]"
187             if 'code_tag' in version: 
188                 label += " %s"%version['code_tag']
189             if 'testbed' in version:
190                 label += " (%s)"%version['testbed']
191         layout['label']=Interface.multi_lines_label(self.url(),label)
192         ### set shape
193         try: shape=Interface.shapes[version['interface']]
194         except: shape=Interface.shapes['default']
195         layout['shape']=shape
196         ### fill color to outline wrongly configured bodies
197         if 'geni_api' not in version and 'sfa' not in version:
198             layout['style']='filled'
199             layout['fillcolor']='gray'
200         return layout
201
202 class SfaScan:
203
204     # provide the entry points (a list of interfaces)
205     def __init__ (self, left_to_right=False, verbose=False):
206         self.verbose=verbose
207         self.left_to_right=left_to_right
208
209     def graph (self,entry_points):
210         graph=pygraphviz.AGraph(directed=True)
211         if self.left_to_right: 
212             graph.graph_attr['rankdir']='LR'
213         self.scan(entry_points,graph)
214         return graph
215     
216     # scan from the given interfaces as entry points
217     def scan(self,interfaces,graph):
218         if not isinstance(interfaces,list):
219             interfaces=[interfaces]
220
221         # remember node to interface mapping
222         node2interface={}
223         # add entry points right away using the interface uid's as a key
224         to_scan=interfaces
225         for i in interfaces: 
226             graph.add_node(i.uid())
227             node2interface[graph.get_node(i.uid())]=i
228         scanned=[]
229         # keep on looping until we reach a fixed point
230         # don't worry about abels and shapes that will get fixed later on
231         while to_scan:
232             for interface in to_scan:
233                 # performing xmlrpc call
234                 version=interface.get_version()
235                 if self.verbose:
236                     logger.info("GetVersion at interface %s"%interface.url())
237                     if not version:
238                         logger.info("<EMPTY GetVersion(); offline or cannot authenticate>")
239                     else: 
240                         for (k,v) in version.iteritems(): 
241                             if not isinstance(v,dict):
242                                 logger.info("\r\t%s:%s"%(k,v))
243                             else:
244                                 logger.info(k)
245                                 for (k1,v1) in v.iteritems():
246                                     logger.info("\r\t\t%s:%s"%(k1,v1))
247                 # 'geni_api' is expected if the call succeeded at all
248                 # 'peers' is needed as well as AMs typically don't have peers
249                 if 'geni_api' in version and 'peers' in version: 
250                     # proceed with neighbours
251                     for (next_name,next_url) in version['peers'].iteritems():
252                         next_interface=Interface(next_url)
253                         # locate or create node in graph
254                         try:
255                             # if found, we're good with this one
256                             next_node=graph.get_node(next_interface.uid())
257                         except:
258                             # otherwise, let's move on with it
259                             graph.add_node(next_interface.uid())
260                             next_node=graph.get_node(next_interface.uid())
261                             node2interface[next_node]=next_interface
262                             to_scan.append(next_interface)
263                         graph.add_edge(interface.uid(),next_interface.uid())
264                 scanned.append(interface)
265                 to_scan.remove(interface)
266             # we've scanned the whole graph, let's get the labels and shapes right
267             for node in graph.nodes():
268                 interface=node2interface.get(node,None)
269                 if interface:
270                     for (k,v) in interface.get_layout().iteritems():
271                         node.attr[k]=v
272                 else:
273                     logger.error("MISSED interface with node %s"%node)
274     
275
276 default_outfiles=['sfa.png','sfa.svg','sfa.dot']
277
278 def main():
279     usage="%prog [options] url-entry-point(s)"
280     parser=OptionParser(usage=usage)
281     parser.add_option("-o","--output",action='append',dest='outfiles',default=[],
282                       help="output filenames (cumulative) - defaults are %r"%default_outfiles)
283     parser.add_option("-l","--left-to-right",action="store_true",dest="left_to_right",default=False,
284                       help="instead of top-to-bottom")
285     parser.add_option("-v", "--verbose", action="count", dest="verbose", default=0,
286                       help="verbose - can be repeated for more verbosity")
287     parser.add_option("-c", "--clear-cache",action='store_true',
288                       dest='clear_cache',default=False,
289                       help='clear/trash version cache and exit')
290     parser.add_option("-s","--show-cache",action='store_true',
291                       dest='show_cache',default=False,
292                       help='show/display version cache')
293     
294     (options,args)=parser.parse_args()
295     if options.show_cache: 
296         VersionCache().show()
297         sys.exit(0)
298     if options.clear_cache:
299         VersionCache().clean()
300         sys.exit(0)
301     if not args:
302         parser.print_help()
303         sys.exit(1)
304         
305     if not options.outfiles:
306         options.outfiles=default_outfiles
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     scanner=SfaScan(left_to_right=options.left_to_right, verbose=bool_verbose)
313     entries = [ Interface(entry) for entry in args ]
314     g=scanner.graph(entries)
315     logger.info("creating layout")
316     g.layout(prog='dot')
317     for outfile in options.outfiles:
318         logger.info("drawing in %s"%outfile)
319         g.draw(outfile)
320     logger.info("done")
321
322 if __name__ == '__main__':
323     main()