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