more smartly sense for vplc instances now that we run build VMs and vplc's in the...
[tests.git] / system / Substrate.py
1 #
2 # Thierry Parmentelat <thierry.parmentelat@inria.fr>
3 # Copyright (C) 2010 INRIA 
4 #
5 # #################### history
6 #
7 # see also Substrate.readme
8 #
9 # This is a complete rewrite of TestResources/Tracker/Pool
10 # we don't use trackers anymore and just probe/sense the running 
11 # boxes to figure out where we are
12 # in order to implement some fairness in the round-robin allocation scheme
13 # we need an indication of the 'age' of each running entity, 
14 # hence the 'timestamp-*' steps in TestPlc
15
16 # this should be much more flexible:
17 # * supports several plc boxes 
18 # * supports several qemu guests per host
19 # * no need to worry about tracker being in sync or not
20 #
21 # #################### howto use
22 #
23 # each site is to write its own LocalSubstrate.py, 
24 # (see e.g. LocalSubstrate.inria.py)
25 # LocalSubstrate.py is expected to be in /root on the testmaster box
26 # and needs to define
27 # MYPLCs
28 # . the vserver-capable boxes used for hosting myplcs
29 # .  and their admissible load (max # of myplcs)
30 # . the pool of DNS-names and IP-addresses available for myplcs
31 # QEMU nodes
32 # . the kvm-qemu capable boxes to host qemu instances
33 # .  and their admissible load (max # of myplcs)
34 # . the pool of DNS-names and IP-addresses available for nodes
35
36 # #################### implem. note
37
38 # this model relies on 'sensing' the substrate, 
39 # i.e. probing all the boxes for their running instances of vservers and qemu
40 # this is how we get rid of tracker inconsistencies 
41 # however there is a 'black hole' between the time where a given address is 
42 # allocated and when it actually gets used/pingable
43 # this is why we still need a shared knowledge among running tests
44 # in a file named /root/starting
45 # this is connected to the Pool class 
46
47 # ####################
48
49 import os.path, sys
50 import time
51 import re
52 import traceback
53 import subprocess
54 import socket
55 from optparse import OptionParser
56
57 import utils
58 from TestSsh import TestSsh
59 from TestMapper import TestMapper
60 from functools import reduce
61
62 # too painful to propagate this cleanly
63 verbose = None
64
65 def header (message, banner=True):
66     if not message: return
67     if banner:
68         print("===============", end=' ')
69     print(message)
70     sys.stdout.flush()
71
72 def timestamp_key(o): return o.timestamp
73
74 def short_hostname (hostname):
75     return hostname.split('.')[0]
76
77 ####################
78 # the place were other test instances tell about their not-yet-started
79 # instances, that go undetected through sensing
80 class Starting:
81
82     location = '/root/starting'
83     
84     def __init__ (self):
85         self.tuples=[]
86
87     def load (self):
88         try:
89             with open(Starting.location) as starting:
90                 self.tuples = [line.strip().split('@') for line in starting.readlines()]
91         except:
92             self.tuples = []
93
94     def vnames (self) : 
95         self.load()
96         return [ x for (x, _) in self.tuples ]
97
98     def add (self, vname, bname):
99         if not vname in self.vnames():
100             with open(Starting.location, 'a') as out:
101                 out.write("{}@{}\n".format(vname, bname))
102             
103     def delete_vname (self, vname):
104         self.load()
105         if vname in self.vnames():
106             with open(Starting.location, 'w') as f:
107                 for (v, b) in self.tuples: 
108                     if v != vname:
109                         f.write("{}@{}\n".format(v, b))
110     
111 ####################
112 # pool class
113 # allows to pick an available IP among a pool
114 # input is expressed as a list of tuples (hostname,ip,user_data)
115 # that can be searched iteratively for a free slot
116 # e.g.
117 # pool = [ (hostname1,user_data1),  
118 #          (hostname2,user_data2),  
119 #          (hostname3,user_data2),  
120 #          (hostname4,user_data4) ]
121 # assuming that ip1 and ip3 are taken (pingable), then we'd get
122 # pool=Pool(pool)
123 # pool.next_free() -> entry2
124 # pool.next_free() -> entry4
125 # pool.next_free() -> None
126 # that is, even if ip2 is not busy/pingable when the second next_free() is issued
127
128 class PoolItem:
129     def __init__ (self, hostname, userdata):
130         self.hostname = hostname
131         self.userdata = userdata
132         # slot holds 'busy' or 'free' or 'mine' or 'starting' or None
133         # 'mine' is for our own stuff, 'starting' from the concurrent tests
134         self.status = None
135         self.ip = None
136
137     def line(self):
138         return "Pooled {} ({}) -> {}".format(self.hostname, self.userdata, self.status)
139
140     def char (self):
141         if   self.status == None:       return '?'
142         elif self.status == 'busy':     return '+'
143         elif self.status == 'free':     return '-'
144         elif self.status == 'mine':     return 'M'
145         elif self.status == 'starting': return 'S'
146
147     def get_ip(self):
148         if self.ip: return self.ip
149         ip=socket.gethostbyname(self.hostname)
150         self.ip=ip
151         return ip
152
153 class Pool:
154
155     def __init__ (self, tuples, message, substrate):
156         self.pool_items = [ PoolItem (hostname, userdata) for (hostname, userdata) in tuples ] 
157         self.message = message
158         # where to send notifications upon load_starting
159         self.substrate = substrate
160
161     def list (self, verbose=False):
162         for i in self.pool_items: print(i.line())
163
164     def line (self):
165         line = self.message
166         for i in self.pool_items: line += ' ' + i.char()
167         return line
168
169     def _item (self, hostname):
170         for i in self.pool_items: 
171             if i.hostname == hostname: return i
172         raise Exception ("Could not locate hostname %s in pool %s"%(hostname,self.message))
173
174     def retrieve_userdata (self, hostname): 
175         return self._item(hostname).userdata
176
177     def get_ip (self, hostname):
178         try:
179             return self._item(hostname).get_ip()
180         except:
181             return socket.gethostbyname(hostname)
182         
183     def set_mine (self, hostname):
184         try:
185             self._item(hostname).status='mine'
186         except:
187             print('WARNING: host %s not found in IP pool %s'%(hostname,self.message))
188
189     def next_free (self):
190         for i in self.pool_items:
191             if i.status == 'free':
192                 i.status='mine'
193                 return (i.hostname, i.userdata)
194         return None
195
196     ####################
197     # we have a starting instance of our own
198     def add_starting (self, vname, bname):
199         Starting().add(vname, bname)
200         for i in self.pool_items:
201             if i.hostname == vname:
202                 i.status = 'mine'
203
204     # load the starting instances from the common file
205     # remember that might be ours
206     # return the list of (vname,bname) that are not ours
207     def load_starting (self):
208         starting = Starting()
209         starting.load()
210         new_tuples = []
211         for (v, b) in starting.tuples:
212             for i in self.pool_items:
213                 if i.hostname == v and i.status == 'free':
214                     i.status = 'starting'
215                     new_tuples.append( (v, b,) )
216         return new_tuples
217
218     def release_my_starting (self):
219         for i in self.pool_items:
220             if i.status == 'mine':
221                 Starting().delete_vname(i.hostname)
222                 i.status = None
223
224
225     ##########
226     def _sense (self):
227         for item in self.pool_items:
228             if item.status is not None: 
229                 print(item.char(), end=' ')
230                 continue
231             if self.check_ping (item.hostname): 
232                 item.status = 'busy'
233                 print('*', end=' ')
234             else:
235                 item.status = 'free'
236                 print('.', end=' ')
237     
238     def sense (self):
239         print('Sensing IP pool', self.message, end=' ')
240         self._sense()
241         print('Done')
242         for (vname,bname) in self.load_starting():
243             self.substrate.add_starting_dummy(bname, vname)
244         print("After having loaded 'starting': IP pool")
245         print(self.line())
246     # OS-dependent ping option (support for macos, for convenience)
247     ping_timeout_option = None
248     # returns True when a given hostname/ip responds to ping
249     def check_ping (self, hostname):
250         if not Pool.ping_timeout_option:
251             (status, osname) = subprocess.getstatusoutput("uname -s")
252             if status != 0:
253                 raise Exception("TestPool: Cannot figure your OS name")
254             if osname == "Linux":
255                 Pool.ping_timeout_option = "-w"
256             elif osname == "Darwin":
257                 Pool.ping_timeout_option = "-t"
258
259         command="ping -c 1 {} 1 {}".format(Pool.ping_timeout_option, hostname)
260         (status, output) = subprocess.getstatusoutput(command)
261         return status == 0
262
263 ####################
264 class Box:
265     def __init__ (self, hostname):
266         self.hostname = hostname
267         self._probed = None
268     def shortname (self):
269         return short_hostname(self.hostname)
270     def test_ssh (self):
271         return TestSsh(self.hostname, username='root', unknown_host=False)
272     def reboot (self, options):
273         self.test_ssh().run("shutdown -r now",
274                             message="Rebooting {}".format(self.hostname),
275                             dry_run=options.dry_run)
276
277     def hostname_fedora (self, virt=None):
278         # this truly is an opening bracket
279         result = "{}".format(self.hostname) + " {"
280         if virt:
281             result += "{}-".format(virt)
282         result += "{} {}".format(self.fedora(), self.memory())
283         # too painful to propagate this cleanly
284         global verbose
285         if verbose:
286             result += "-{}".format(self.uname())
287         # and the matching closing bracket
288         result += "}"
289         return result
290
291     separator = "===composite==="
292
293     # probe the ssh link
294     # take this chance to gather useful stuff
295     def probe (self):
296         # try it only once
297         if self._probed is not None: return self._probed
298         composite_command = [ ]
299         composite_command += [ "hostname" ]
300         composite_command += [ ";" , "echo", Box.separator , ";" ]
301         composite_command += [ "uptime" ]
302         composite_command += [ ";" , "echo", Box.separator , ";" ]
303         composite_command += [ "uname", "-r"]
304         composite_command += [ ";" , "echo", Box.separator , ";" ]
305         composite_command += [ "cat" , "/etc/fedora-release" ]
306         composite_command += [ ";" , "echo", Box.separator , ";" ]
307         composite_command += [ "grep", "MemTotal", "/proc/meminfo" ]
308
309         # due to colons and all, this is going wrong on the local box (typically testmaster)
310         # I am reluctant to change TestSsh as it might break all over the place, so
311         if self.test_ssh().is_local():
312             probe_argv = [ "bash", "-c", " ".join (composite_command) ]
313         else:
314             probe_argv = self.test_ssh().actual_argv(composite_command)
315         composite = self.backquote ( probe_argv, trash_err=True )
316         self._hostname = self._uptime = self._uname = self._fedora = self._memory = "** Unknown **"
317         if not composite: 
318             print("root@{} unreachable".format(self.hostname))
319             self._probed = ''
320         else:
321             try:
322                 pieces = composite.split(Box.separator)
323                 pieces = [ x.strip() for x in pieces ]
324                 # get raw data
325                 [hostname, uptime, uname, fedora, memory] = pieces
326                 # customize
327                 self._hostname = hostname
328                 self._uptime = ', '.join([ x.strip() for x in uptime.split(',')[2:]]).replace("load average", "load")
329                 self._uname = uname
330                 self._fedora = fedora.replace("Fedora release ","f").split(" ")[0]
331                 # translate into Mb
332                 self._memory = int(memory.split()[1])/(1024)
333             except Exception as e:
334                 import traceback
335                 print('BEG issue with pieces')
336                 traceback.print_exc()
337             self._probed = self._hostname
338         return self._probed
339
340     # use argv=['bash','-c',"the command line"]
341     def uptime(self):
342         self.probe()
343         if hasattr(self,'_uptime') and self._uptime: return self._uptime
344         return '*unprobed* uptime'
345     def uname(self):
346         self.probe()
347         if hasattr(self,'_uname') and self._uname: return self._uname
348         return '*unprobed* uname'
349     def fedora(self):
350         self.probe()
351         if hasattr(self,'_fedora') and self._fedora: return self._fedora
352         return '*unprobed* fedora'
353     def memory(self):
354         self.probe()
355         if hasattr(self,'_memory') and self._memory: return "{} Mb".format(self._memory)
356         return '*unprobed* memory'
357
358     def run(self, argv, message=None, trash_err=False, dry_run=False):
359         if dry_run:
360             print('DRY_RUN:', end=' ')
361             print(" ".join(argv))
362             return 0
363         else:
364             header(message)
365             if not trash_err:
366                 return subprocess.call(argv)
367             else:
368                 with open('/dev/null', 'w') as null:
369                     return subprocess.call(argv, stderr=null)
370                 
371     def run_ssh (self, argv, message, trash_err=False, dry_run=False):
372         ssh_argv = self.test_ssh().actual_argv(argv)
373         result = self.run (ssh_argv, message, trash_err, dry_run=dry_run)
374         if result != 0:
375             print("WARNING: failed to run {} on {}".format(" ".join(argv), self.hostname))
376         return result
377
378     def backquote (self, argv, trash_err=False):
379         # in python3 we need to set universal_newlines=True
380         if not trash_err:
381             out_err = subprocess.Popen(argv, stdout=subprocess.PIPE,
382                                        universal_newlines=True).communicate()
383         else:
384             with open('/dev/null', 'w') as null:
385                 out_err = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=null,
386                                            universal_newlines=True).communicate()
387         # only interested in stdout here
388         return out_err[0]
389
390     # if you have any shell-expanded arguments like *
391     # and if there's any chance the command is adressed to the local host
392     def backquote_ssh (self, argv, trash_err=False):
393         if not self.probe(): return ''
394         return self.backquote(self.test_ssh().actual_argv(argv), trash_err)
395
396 ############################################################
397 class BuildInstance:
398     def __init__ (self, buildname, pid, buildbox):
399         self.buildname = buildname
400         self.buildbox = buildbox
401         self.pids = [pid]
402
403     def add_pid(self,pid):
404         self.pids.append(pid)
405
406     def line (self):
407         return "== {} == (pids={})".format(self.buildname, self.pids)
408
409 class BuildBox (Box):
410     def __init__ (self, hostname):
411         Box.__init__(self, hostname)
412         self.build_instances = []
413
414     def add_build(self, buildname, pid):
415         for build in self.build_instances:
416             if build.buildname == buildname: 
417                 build.add_pid(pid)
418                 return
419         self.build_instances.append(BuildInstance(buildname, pid, self))
420
421     def list(self, verbose=False):
422         if not self.build_instances: 
423             header ('No build process on {} ({})'.format(self.hostname_fedora(), self.uptime()))
424         else:
425             header ("Builds on {} ({})".format(self.hostname_fedora(), self.uptime()))
426             for b in self.build_instances: 
427                 header (b.line(), banner=False)
428
429     def reboot (self, options):
430         if not options.soft:
431             Box.reboot(self, options)
432         else:
433             self.soft_reboot (options)
434
435 build_matcher=re.compile("\s*(?P<pid>[0-9]+).*-[bo]\s+(?P<buildname>[^\s]+)(\s|\Z)")
436 build_matcher_initvm=re.compile("\s*(?P<pid>[0-9]+).*initvm.*\s+(?P<buildname>[^\s]+)\s*\Z")
437
438 class BuildLxcBox (BuildBox):
439     def soft_reboot (self, options):
440             command=['pkill','lbuild']
441             self.run_ssh(command, "Terminating vbuild processes", dry_run=options.dry_run)
442
443     # inspect box and find currently running builds
444     def sense(self, options):
445         print('xb', end=' ')
446         pids = self.backquote_ssh(['pgrep','lbuild'], trash_err=True)
447         if not pids: return
448         command = ['ps', '-o', 'pid,command'] + [ pid for pid in pids.split("\n") if pid]
449         ps_lines = self.backquote_ssh(command).split('\n')
450         for line in ps_lines:
451             if not line.strip() or line.find('PID') >= 0: continue
452             m = build_matcher.match(line)
453             if m: 
454                 date = time.strftime('%Y-%m-%d', time.localtime(time.time()))
455                 buildname = m.group('buildname').replace('@DATE@', date)
456                 self.add_build(buildname, m.group('pid'))
457                 continue
458             m = build_matcher_initvm.match(line)
459             if m: 
460                 # buildname is expansed here
461                 self.add_build(buildname, m.group('pid'))
462                 continue
463             header('BuildLxcBox.sense: command {} returned line that failed to match'.format(command))
464             header(">>{}<<".format(line))
465     
466 ############################################################
467 class PlcInstance:
468     def __init__ (self, plcbox):
469         self.plc_box = plcbox
470         # unknown yet
471         self.timestamp = 0
472         
473     def set_timestamp (self,timestamp):
474         self.timestamp = timestamp
475     def set_now (self):
476         self.timestamp = int(time.time())
477     def pretty_timestamp (self):
478         return time.strftime("%Y-%m-%d:%H-%M", time.localtime(self.timestamp))
479
480 class PlcLxcInstance (PlcInstance):
481     # does lxc have a context id of any kind ?
482     def __init__ (self, plcbox, lxcname, pid):
483         PlcInstance.__init__(self, plcbox)
484         self.lxcname = lxcname
485         self.pid = pid
486
487     def vplcname (self):
488         return self.lxcname.split('-')[-1]
489     def buildname (self):
490         return self.lxcname.rsplit('-',2)[0]
491
492     def line (self):
493         msg="== {} ==".format(self.vplcname())
494         msg += " [={}]".format(self.lxcname)
495         if self.pid==-1:  msg+=" not (yet?) running"
496         else:              msg+=" (pid={})".format(self.pid)
497         if self.timestamp: msg += " @ {}".format(self.pretty_timestamp())
498         else:              msg += " *unknown timestamp*"
499         return msg
500
501     def kill (self):
502         command="rsync lxc-driver.sh  {}:/root".format(self.plc_box.hostname)
503         subprocess.getstatusoutput(command)
504         msg="lxc container stopping {} on {}".format(self.lxcname, self.plc_box.hostname)
505         self.plc_box.run_ssh(['/root/lxc-driver.sh', '-c', 'stop_lxc', '-n', self.lxcname], msg)
506         self.plc_box.forget(self)
507
508 ##########
509 class PlcBox (Box):
510     def __init__ (self, hostname, max_plcs):
511         Box.__init__(self, hostname)
512         self.plc_instances = []
513         self.max_plcs = max_plcs
514
515     def free_slots (self):
516         return self.max_plcs - len(self.plc_instances)
517
518     # fill one slot even though this one is not started yet
519     def add_dummy (self, plcname):
520         dummy=PlcLxcInstance(self, 'dummy_'+plcname, 0)
521         dummy.set_now()
522         self.plc_instances.append(dummy)
523
524     def forget (self, plc_instance):
525         self.plc_instances.remove(plc_instance)
526
527     def reboot (self, options):
528         if not options.soft:
529             Box.reboot(self, options)
530         else:
531             self.soft_reboot (options)
532
533     def list(self, verbose=False):
534         if not self.plc_instances: 
535             header ('No plc running on {}'.format(self.line()))
536         else:
537             header ("Active plc VMs on {}".format(self.line()))
538             self.plc_instances.sort(key=timestamp_key)
539             for p in self.plc_instances: 
540                 header (p.line(), banner=False)
541
542 ## we do not this at INRIA any more
543 class PlcLxcBox (PlcBox):
544
545     def add_lxc (self, lxcname, pid):
546         for plc in self.plc_instances:
547             if plc.lxcname == lxcname:
548                 header("WARNING, duplicate myplc {} running on {}"\
549                        .format(lxcname, self.hostname), banner=False)
550                 return
551         self.plc_instances.append(PlcLxcInstance(self, lxcname, pid))    
552
553
554     # a line describing the box
555     def line(self): 
556         return "{} [max={},free={}] ({})".format(self.hostname_fedora(virt="lxc"), 
557                                                  self.max_plcs, self.free_slots(),
558                                                  self.uptime())
559     
560     def plc_instance_by_lxcname(self, lxcname):
561         for p in self.plc_instances:
562             if p.lxcname == lxcname:
563                 return p
564         return None
565     
566     # essentially shutdown all running containers
567     def soft_reboot(self, options):
568         command="rsync lxc-driver.sh  {}:/root".format(self.hostname)
569         subprocess.getstatusoutput(command)
570         self.run_ssh( ['/root/lxc-driver.sh','-c','stop_all'],
571                       "Stopping all running lxc containers on {}".format(self.hostname),
572                       dry_run=options.dry_run)
573
574
575     # sense is expected to fill self.plc_instances with PlcLxcInstance's 
576     # to describe the currently running VM's
577     def sense(self, options):
578         print("xp", end=' ')
579         command = "rsync lxc-driver.sh  {}:/root".format(self.hostname)
580         subprocess.getstatusoutput(command)
581         command = ['/root/lxc-driver.sh', '-c', 'sense_all']
582         lxc_stat = self.backquote_ssh (command)
583         for lxc_line in lxc_stat.split("\n"):
584             if not lxc_line:
585                 continue
586             if 'vplc' not in lxc_line:
587                 continue
588             lxcname = lxc_line.split(";")[0]
589             pid = lxc_line.split(";")[1]
590             timestamp = lxc_line.split(";")[2]
591             self.add_lxc(lxcname,pid)
592             try: timestamp = int(timestamp)
593             except: timestamp = 0
594             p = self.plc_instance_by_lxcname(lxcname)
595             if not p:
596                 print('WARNING zombie plc',self.hostname,lxcname)
597                 print('... was expecting',lxcname,'in',[i.lxcname for i in self.plc_instances])
598                 continue
599             p.set_timestamp(timestamp)
600
601 ############################################################
602 class QemuInstance: 
603     def __init__(self, nodename, pid, qemubox):
604         self.nodename = nodename
605         self.pid = pid
606         self.qemu_box = qemubox
607         # not known yet
608         self.buildname = None
609         self.timestamp = 0
610         
611     def set_buildname (self, buildname):
612         self.buildname = buildname
613     def set_timestamp (self, timestamp):
614         self.timestamp = timestamp
615     def set_now (self):
616         self.timestamp = int(time.time())
617     def pretty_timestamp (self):
618         return time.strftime("%Y-%m-%d:%H-%M", time.localtime(self.timestamp))
619     
620     def line (self):
621         msg = "== {} ==".format(short_hostname(self.nodename))
622         msg += " [={}]".format(self.buildname)
623         if self.pid:       msg += " (pid={})".format(self.pid)
624         else:              msg += " not (yet?) running"
625         if self.timestamp: msg += " @ {}".format(self.pretty_timestamp())
626         else:              msg += " *unknown timestamp*"
627         return msg
628     
629     def kill(self):
630         if self.pid == 0: 
631             print("cannot kill qemu {} with pid==0".format(self.nodename))
632             return
633         msg = "Killing qemu {} with pid={} on box {}".format(self.nodename, self.pid, self.qemu_box.hostname)
634         self.qemu_box.run_ssh(['kill', "{}".format(self.pid)], msg)
635         self.qemu_box.forget(self)
636
637
638 class QemuBox (Box):
639     def __init__ (self, hostname, max_qemus):
640         Box.__init__(self, hostname)
641         self.qemu_instances = []
642         self.max_qemus = max_qemus
643
644     def add_node(self, nodename, pid):
645         for qemu in self.qemu_instances:
646             if qemu.nodename == nodename: 
647                 header("WARNING, duplicate qemu {} running on {}"\
648                        .format(nodename,self.hostname), banner=False)
649                 return
650         self.qemu_instances.append(QemuInstance(nodename, pid, self))
651
652     def node_names (self):
653         return [ qi.nodename for qi in self.qemu_instances ]
654
655     def forget (self, qemu_instance):
656         self.qemu_instances.remove(qemu_instance)
657
658     # fill one slot even though this one is not started yet
659     def add_dummy(self, nodename):
660         dummy=QemuInstance('dummy_'+nodename, 0, self)
661         dummy.set_now()
662         self.qemu_instances.append(dummy)
663
664     def line (self):
665         return "{} [max={},free={}] ({}) {}"\
666             .format(self.hostname_fedora(virt="qemu"),
667                     self.max_qemus, self.free_slots(),
668                     self.uptime(), self.driver())
669
670     def list(self, verbose=False):
671         if not self.qemu_instances: 
672             header ('No qemu on {}'.format(self.line()))
673         else:
674             header ("Qemus on {}".format(self.line()))
675             self.qemu_instances.sort(key=timestamp_key)
676             for q in self.qemu_instances: 
677                 header (q.line(), banner=False)
678
679     def free_slots (self):
680         return self.max_qemus - len(self.qemu_instances)
681
682     def driver(self):
683         if hasattr(self,'_driver') and self._driver:
684             return self._driver
685         return '*undef* driver'
686
687     def qemu_instance_by_pid(self, pid):
688         for q in self.qemu_instances:
689             if q.pid == pid:
690                 return q
691         return None
692
693     def qemu_instance_by_nodename_buildname (self, nodename, buildname):
694         for q in self.qemu_instances:
695             if q.nodename == nodename and q.buildname == buildname:
696                 return q
697         return None
698
699     def reboot (self, options):
700         if not options.soft:
701             Box.reboot(self, options)
702         else:
703             self.run_ssh(['pkill','qemu'], "Killing qemu instances",
704                          dry_run=options.dry_run)
705
706     matcher=re.compile("\s*(?P<pid>[0-9]+).*-cdrom\s+(?P<nodename>[^\s]+)\.iso")
707
708     def sense(self, options):
709         print('qn', end=' ')
710         modules = self.backquote_ssh(['lsmod']).split('\n')
711         self._driver = '*NO kqemu/kvm_intel MODULE LOADED*'
712         for module in modules:
713             if module.find('kqemu') == 0:
714                 self._driver = 'kqemu module loaded'
715             # kvm might be loaded without kvm_intel (we dont have AMD)
716             elif module.find('kvm_intel') == 0:
717                 self._driver = 'kvm_intel OK'
718         ########## find out running pids
719         pids = self.backquote_ssh(['pgrep','qemu'])
720         if not pids:
721             return
722         command = ['ps','-o','pid,command'] + [ pid for pid in pids.split("\n") if pid]
723         ps_lines = self.backquote_ssh(command).split("\n")
724         for line in ps_lines:
725             if not line.strip() or line.find('PID') >=0 :
726                 continue
727             m = QemuBox.matcher.match(line)
728             if m: 
729                 self.add_node(m.group('nodename'), m.group('pid'))
730                 continue
731             header('QemuBox.sense: command {} returned line that failed to match'.format(command))
732             header(">>{}<<".format(line))
733         ########## retrieve alive instances and map to build
734         live_builds=[]
735         command = ['grep', '.', '/vservers/*/*/qemu.pid', '/dev/null']
736         pid_lines = self.backquote_ssh(command, trash_err=True).split('\n')
737         for pid_line in pid_lines:
738             if not pid_line.strip():
739                 continue
740             # expect <build>/<nodename>/qemu.pid:<pid>pid
741             try:
742                 (_, __, buildname, nodename, tail) = pid_line.split('/')
743                 (_,pid) = tail.split(':')
744                 q = self.qemu_instance_by_pid(pid)
745                 if not q:
746                     continue
747                 q.set_buildname(buildname)
748                 live_builds.append(buildname)
749             except:
750                 print('WARNING, could not parse pid line', pid_line)
751         # retrieve timestamps
752         if not live_builds:
753             return
754         command =  ['grep','.']
755         command += ['/vservers/{}/*/timestamp'.format(b) for b in live_builds]
756         command += ['/dev/null']
757         ts_lines = self.backquote_ssh(command, trash_err=True).split('\n')
758         for ts_line in ts_lines:
759             if not ts_line.strip():
760                 continue
761             # expect <build>/<nodename>/timestamp:<timestamp>
762             try:
763                 (_, __, buildname, nodename, tail) = ts_line.split('/')
764                 nodename = nodename.replace('qemu-', '')
765                 (_, timestamp) = tail.split(':')
766                 timestamp = int(timestamp)
767                 q = self.qemu_instance_by_nodename_buildname(nodename, buildname)
768                 if not q: 
769                     # this warning corresponds to qemu instances that were not killed properly 
770                     # and that have a dangling qemu.pid - and not even all of them as they need
771                     # to be attached to a build that has a node running...
772                     # it is more confusing than helpful, so let's just trash it
773                     #print 'WARNING zombie qemu',self.hostname,ts_line
774                     #print '... was expecting (',short_hostname(nodename),buildname,') in',\
775                     #    [ (short_hostname(i.nodename),i.buildname) for i in self.qemu_instances ]
776                     continue
777                 q.set_timestamp(timestamp)
778             except:
779                 print('WARNING, could not parse ts line',ts_line)
780
781 ####################
782 class TestInstance:
783     def __init__(self, buildname, pid=0):
784         self.pids = []
785         if pid != 0:
786             self.pid.append(pid)
787         self.buildname = buildname
788         # latest trace line
789         self.trace = ''
790         # has a KO test
791         self.broken_steps = []
792         self.timestamp = 0
793
794     def set_timestamp(self, timestamp):
795         self.timestamp = timestamp
796     def set_now(self):
797         self.timestamp = int(time.time())
798     def pretty_timestamp(self):
799         return time.strftime("%Y-%m-%d:%H-%M", time.localtime(self.timestamp))
800     def is_running (self):
801         return len(self.pids) != 0
802     def add_pid(self, pid):
803         self.pids.append(pid)
804     def set_broken(self, plcindex, step): 
805         self.broken_steps.append( (plcindex, step,) )
806
807     def second_letter(self):
808         if not self.broken_steps:
809             return '='
810         else:
811             really_broken = [ step for (i,step) in self.broken_steps if '_ignore' not in step ]
812             # W is for warning like what's in the build mail
813             if len(really_broken) == 0:
814                 return 'W'
815             else:
816                 return 'B'
817
818     def line (self):
819         # make up a 2-letter sign
820         # first letter : '=', unless build is running : '*'
821         double = '*' if self.pids else '='
822         # second letter : '=' if fine, 'W' for warnings (only ignored steps) 'B' for broken
823         letter2 = self.second_letter()
824         double += letter2
825         msg = " {} {} ==".format(double, self.buildname)
826         if not self.pids:
827             pass
828         elif len(self.pids)==1:
829             msg += " (pid={})".format(self.pids[0])
830         else:
831             msg += " !!!pids={}!!!".format(self.pids)
832         msg += " @{}".format(self.pretty_timestamp())
833         if letter2 != '=':
834             msg2 = ( ' BROKEN' if letter2 == 'B' else ' WARNING' )
835             # sometimes we have an empty plcindex
836             msg += " [{}=".format(msg2) \
837                    + " ".join(["{}@{}".format(s, i) if i else s for (i, s) in self.broken_steps]) \
838                    + "]"
839         return msg
840
841 class TestBox(Box):
842     def __init__(self, hostname):
843         Box.__init__(self, hostname)
844         self.starting_ips = []
845         self.test_instances = []
846
847     def reboot(self, options):
848         # can't reboot a vserver VM
849         self.run_ssh(['pkill', 'run_log'], "Terminating current runs",
850                       dry_run=options.dry_run)
851         self.run_ssh(['rm', '-f', Starting.location], "Cleaning {}".format(Starting.location),
852                       dry_run=options.dry_run)
853
854     def get_test(self, buildname):
855         for i in self.test_instances:
856             if i.buildname == buildname:
857                 return i
858
859     # we scan ALL remaining test results, even the ones not running
860     def add_timestamp(self, buildname, timestamp):
861         i = self.get_test(buildname)
862         if i:   
863             i.set_timestamp(timestamp)
864         else:   
865             i = TestInstance(buildname, 0)
866             i.set_timestamp(timestamp)
867             self.test_instances.append(i)
868
869     def add_running_test(self, pid, buildname):
870         i = self.get_test(buildname)
871         if not i:
872             self.test_instances.append(TestInstance(buildname, pid))
873             return
874         if i.pids:
875             print("WARNING: 2 concurrent tests run on same build {}".format(buildname))
876         i.add_pid(pid)
877
878     def add_broken(self, buildname, plcindex, step):
879         i = self.get_test(buildname)
880         if not i:
881             i = TestInstance(buildname)
882             self.test_instances.append(i)
883         i.set_broken(plcindex, step)
884
885     matcher_proc=re.compile (".*/proc/(?P<pid>[0-9]+)/cwd.*/root/(?P<buildname>[^/]+)$")
886     matcher_grep=re.compile ("/root/(?P<buildname>[^/]+)/logs/trace.*:TRACE:\s*(?P<plcindex>[0-9]+).*step=(?P<step>\S+).*")
887     matcher_grep_missing=re.compile ("grep: /root/(?P<buildname>[^/]+)/logs/trace: No such file or directory")
888
889     def sense(self, options):
890         print('tm', end=' ')
891         self.starting_ips = [x for x in self.backquote_ssh(['cat',Starting.location], trash_err=True).strip().split('\n') if x]
892
893         # scan timestamps on all tests
894         # this is likely to not invoke ssh so we need to be a bit smarter to get * expanded
895         # xxx would make sense above too
896         command = ['bash', '-c', "grep . /root/*/timestamp /dev/null"]
897         ts_lines = self.backquote_ssh(command, trash_err=True).split('\n')
898         for ts_line in ts_lines:
899             if not ts_line.strip():
900                 continue
901             # expect /root/<buildname>/timestamp:<timestamp>
902             try:
903                 (ts_file, timestamp) = ts_line.split(':')
904                 ts_file = os.path.dirname(ts_file)
905                 buildname = os.path.basename(ts_file)
906                 timestamp = int(timestamp)
907                 t = self.add_timestamp(buildname, timestamp)
908             except:
909                 print('WARNING, could not parse ts line', ts_line)
910
911         # let's try to be robust here -- tests that fail very early like e.g.
912         # "Cannot make space for a PLC instance: vplc IP pool exhausted", that occurs as part of provision
913         # will result in a 'trace' symlink to an inexisting 'trace-<>.txt' because no step has gone through
914         # simple 'trace' should exist though as it is created by run_log
915         command = ['bash', '-c', "grep KO /root/*/logs/trace /dev/null 2>&1" ]
916         trace_lines = self.backquote_ssh(command).split('\n')
917         for line in trace_lines:
918             if not line.strip():
919                 continue
920             m = TestBox.matcher_grep_missing.match(line)
921             if m:
922                 buildname = m.group('buildname')
923                 self.add_broken(buildname, '', 'NO STEP DONE')
924                 continue
925             m = TestBox.matcher_grep.match(line)
926             if m: 
927                 buildname = m.group('buildname')
928                 plcindex = m.group('plcindex')
929                 step = m.group('step')
930                 self.add_broken(buildname, plcindex, step)
931                 continue
932             header("TestBox.sense: command {} returned line that failed to match\n{}".format(command, line))
933             header(">>{}<<".format(line))
934
935         pids = self.backquote_ssh (['pgrep', 'run_log'], trash_err=True)
936         if not pids:
937             return
938         command = ['ls','-ld'] + ["/proc/{}/cwd".format(pid) for pid in pids.split("\n") if pid]
939         ps_lines = self.backquote_ssh(command).split('\n')
940         for line in ps_lines:
941             if not line.strip():
942                 continue
943             m = TestBox.matcher_proc.match(line)
944             if m: 
945                 pid = m.group('pid')
946                 buildname = m.group('buildname')
947                 self.add_running_test(pid, buildname)
948                 continue
949             header("TestBox.sense: command {} returned line that failed to match\n{}".format(command, line))
950             header(">>{}<<".format(line))
951         
952         
953     def line (self):
954         return self.hostname_fedora()
955
956     def list (self, verbose=False):
957         # verbose shows all tests
958         if verbose:
959             instances = self.test_instances
960             msg="tests"
961         else:
962             instances = [ i for i in self.test_instances if i.is_running() ]
963             msg="running tests"
964
965         if not instances:
966             header ("No {} on {}".format(msg, self.line()))
967         else:
968             header ("{} on {}".format(msg, self.line()))
969             instances.sort(key=timestamp_key)
970             for i in instances:
971                 print(i.line())
972         # show 'starting' regardless of verbose
973         if self.starting_ips:
974             header("Starting IP addresses on {}".format(self.line()))
975             self.starting_ips.sort()
976             for starting in self.starting_ips:
977                 print(starting)
978         else:
979             header("Empty 'starting' on {}".format(self.line()))
980
981 ############################################################
982 class Options: pass
983
984 class Substrate:
985
986     def __init__(self):
987         self.options = Options()
988         self.options.dry_run = False
989         self.options.verbose = False
990         self.options.reboot = False
991         self.options.soft = False
992         self.test_box = TestBox (self.test_box_spec())
993         self.build_lxc_boxes = [ BuildLxcBox(h) for h in self.build_lxc_boxes_spec() ]
994         self.plc_lxc_boxes = [ PlcLxcBox (h, m) for (h, m) in self.plc_lxc_boxes_spec ()]
995         self.qemu_boxes = [ QemuBox (h, m) for (h, m) in self.qemu_boxes_spec ()]
996         self._sensed = False
997
998         self.vplc_pool = Pool(self.vplc_ips(), "for vplcs", self)
999         self.vnode_pool = Pool(self.vnode_ips(), "for vnodes", self)
1000         
1001         self.build_boxes = self.build_lxc_boxes
1002         self.plc_boxes = self.plc_lxc_boxes
1003         self.default_boxes = self.plc_boxes + self.qemu_boxes
1004         self.all_boxes = self.build_boxes + [ self.test_box ] + self.plc_boxes + self.qemu_boxes
1005
1006     def summary_line (self):
1007         msg  = "["
1008         msg += " {} xp".format(len(self.plc_lxc_boxes))
1009         msg += " {} tried plc boxes".format(len(self.plc_boxes))
1010         msg += "]"
1011         return msg
1012
1013     def fqdn (self, hostname):
1014         if hostname.find('.') < 0:
1015             return "{}.{}".format(hostname, self.domain())
1016         return hostname
1017
1018     # return True if actual sensing takes place
1019     def sense(self, force=False):
1020         if self._sensed and not force:
1021             return False
1022         print('Sensing local substrate...', end=' ')
1023         for b in self.default_boxes:
1024             b.sense(self.options)
1025         print('Done')
1026         self._sensed = True
1027         return True
1028
1029     def list(self, verbose=False):
1030         for b in self.default_boxes:
1031             b.list()
1032
1033     def add_dummy_plc(self, plc_boxname, plcname):
1034         for pb in self.plc_boxes:
1035             if pb.hostname == plc_boxname:
1036                 pb.add_dummy(plcname)
1037                 return True
1038     def add_dummy_qemu(self, qemu_boxname, qemuname):
1039         for qb in self.qemu_boxes:
1040             if qb.hostname == qemu_boxname:
1041                 qb.add_dummy(qemuname)
1042                 return True
1043
1044     def add_starting_dummy(self, bname, vname):
1045         return self.add_dummy_plc(bname, vname) or self.add_dummy_qemu(bname, vname)
1046
1047     ########## 
1048     def provision(self, plcs, options):
1049         try:
1050             # attach each plc to a plc box and an IP address
1051             plcs = [ self.provision_plc(plc, options) for plc in plcs ]
1052             # attach each node/qemu to a qemu box with an IP address
1053             plcs = [ self.provision_qemus(plc,options) for plc in plcs ]
1054             # update the SFA spec accordingly
1055             plcs = [ self.localize_sfa_rspec(plc, options) for plc in plcs ]
1056             self.list()
1057             return plcs
1058         except Exception as e:
1059             print('* Could not provision this test on current substrate','--',e,'--','exiting')
1060             traceback.print_exc()
1061             sys.exit(1)
1062
1063     # it is expected that a couple of options like ips_bplc and ips_vplc 
1064     # are set or unset together
1065     @staticmethod
1066     def check_options(x, y):
1067         if not x and not y:
1068             return True
1069         return len(x) == len(y)
1070
1071     # find an available plc box (or make space)
1072     # and a free IP address (using options if present)
1073     def provision_plc(self, plc, options):
1074         
1075         assert Substrate.check_options(options.ips_bplc, options.ips_vplc)
1076
1077         #### let's find an IP address for that plc
1078         # look in options 
1079         if options.ips_vplc:
1080             # this is a rerun
1081             # we don't check anything here, 
1082             # it is the caller's responsability to cleanup and make sure this makes sense
1083             plc_boxname = options.ips_bplc.pop()
1084             vplc_hostname = options.ips_vplc.pop()
1085         else:
1086             if self.sense():
1087                 self.list()
1088             plc_boxname = None
1089             vplc_hostname = None
1090             # try to find an available IP 
1091             self.vplc_pool.sense()
1092             couple = self.vplc_pool.next_free()
1093             if couple:
1094                 (vplc_hostname, unused) = couple
1095             #### we need to find one plc box that still has a slot
1096             max_free = 0
1097             # use the box that has max free spots for load balancing
1098             for pb in self.plc_boxes:
1099                 free = pb.free_slots()
1100                 if free > max_free:
1101                     plc_boxname = pb.hostname
1102                     max_free = free
1103             # if there's no available slot in the plc_boxes, or we need a free IP address
1104             # make space by killing the oldest running instance
1105             if not plc_boxname or not vplc_hostname:
1106                 # find the oldest of all our instances
1107                 all_plc_instances = reduce(lambda x, y: x+y, 
1108                                            [ pb.plc_instances for pb in self.plc_boxes ],
1109                                            [])
1110                 all_plc_instances.sort(key=timestamp_key)
1111                 try:
1112                     plc_instance_to_kill = all_plc_instances[0]
1113                 except:
1114                     msg = ""
1115                     if not plc_boxname:
1116                         msg += " PLC boxes are full"
1117                     if not vplc_hostname:
1118                         msg += " vplc IP pool exhausted"
1119                     msg += " {}".format(self.summary_line())
1120                     raise Exception("Cannot make space for a PLC instance:" + msg)
1121                 freed_plc_boxname = plc_instance_to_kill.plc_box.hostname
1122                 freed_vplc_hostname = plc_instance_to_kill.vplcname()
1123                 message = 'killing oldest plc instance = {} on {}'\
1124                     .format(plc_instance_to_kill.line(), freed_plc_boxname)
1125                 plc_instance_to_kill.kill()
1126                 # use this new plcbox if that was the problem
1127                 if not plc_boxname:
1128                     plc_boxname = freed_plc_boxname
1129                 # ditto for the IP address
1130                 if not vplc_hostname:
1131                     vplc_hostname = freed_vplc_hostname
1132                     # record in pool as mine
1133                     self.vplc_pool.set_mine(vplc_hostname)
1134
1135         # 
1136         self.add_dummy_plc(plc_boxname, plc['name'])
1137         vplc_ip = self.vplc_pool.get_ip(vplc_hostname)
1138         self.vplc_pool.add_starting(vplc_hostname, plc_boxname)
1139
1140         #### compute a helpful vserver name
1141         # remove domain in hostname
1142         vplc_short = short_hostname(vplc_hostname)
1143         vservername = "{}-{}-{}".format(options.buildname, plc['index'], vplc_short)
1144         plc_name = "{}_{}".format(plc['name'], vplc_short)
1145
1146         utils.header('PROVISION plc {} in box {} at IP {} as {}'\
1147                      .format(plc['name'], plc_boxname, vplc_hostname, vservername))
1148
1149         #### apply in the plc_spec
1150         # # informative
1151         # label = options.personality.replace("linux","")
1152         mapper = {'plc' : [ ('*' , {'host_box' : plc_boxname,
1153                                     'name' : plc_name,
1154                                     'vservername' : vservername,
1155                                     'vserverip' : vplc_ip,
1156                                     'settings:PLC_DB_HOST' : vplc_hostname,
1157                                     'settings:PLC_API_HOST' : vplc_hostname,
1158                                     'settings:PLC_BOOT_HOST' : vplc_hostname,
1159                                     'settings:PLC_WWW_HOST' : vplc_hostname,
1160                                     'settings:PLC_NET_DNS1' : self.network_settings() [ 'interface_fields:dns1' ],
1161                                     'settings:PLC_NET_DNS2' : self.network_settings() [ 'interface_fields:dns2' ],
1162                                 } ) ]
1163               }
1164
1165
1166         # mappers only work on a list of plcs
1167         return TestMapper([plc], options).map(mapper)[0]
1168
1169     ##########
1170     def provision_qemus(self, plc, options):
1171
1172         assert Substrate.check_options(options.ips_bnode, options.ips_vnode)
1173
1174         test_mapper = TestMapper([plc], options)
1175         nodenames = test_mapper.node_names()
1176         maps = []
1177         for nodename in nodenames:
1178
1179             if options.ips_vnode:
1180                 # as above, it's a rerun, take it for granted
1181                 qemu_boxname = options.ips_bnode.pop()
1182                 vnode_hostname = options.ips_vnode.pop()
1183             else:
1184                 if self.sense():
1185                     self.list()
1186                 qemu_boxname = None
1187                 vnode_hostname = None
1188                 # try to find an available IP 
1189                 self.vnode_pool.sense()
1190                 couple = self.vnode_pool.next_free()
1191                 if couple:
1192                     (vnode_hostname, unused) = couple
1193                 # find a physical box
1194                 max_free = 0
1195                 # use the box that has max free spots for load balancing
1196                 for qb in self.qemu_boxes:
1197                     free = qb.free_slots()
1198                     if free>max_free:
1199                         qemu_boxname = qb.hostname
1200                         max_free = free
1201                 # if we miss the box or the IP, kill the oldest instance
1202                 if not qemu_boxname or not vnode_hostname:
1203                 # find the oldest of all our instances
1204                     all_qemu_instances = reduce(lambda x, y: x+y, 
1205                                                 [ qb.qemu_instances for qb in self.qemu_boxes ],
1206                                                 [])
1207                     all_qemu_instances.sort(key=timestamp_key)
1208                     try:
1209                         qemu_instance_to_kill = all_qemu_instances[0]
1210                     except:
1211                         msg = ""
1212                         if not qemu_boxname:
1213                             msg += " QEMU boxes are full"
1214                         if not vnode_hostname:
1215                             msg += " vnode IP pool exhausted" 
1216                         msg += " {}".format(self.summary_line())
1217                         raise Exception("Cannot make space for a QEMU instance:"+msg)
1218                     freed_qemu_boxname = qemu_instance_to_kill.qemu_box.hostname
1219                     freed_vnode_hostname = short_hostname(qemu_instance_to_kill.nodename)
1220                     # kill it
1221                     message = 'killing oldest qemu node = {} on {}'.format(qemu_instance_to_kill.line(),
1222                                                                            freed_qemu_boxname)
1223                     qemu_instance_to_kill.kill()
1224                     # use these freed resources where needed
1225                     if not qemu_boxname:
1226                         qemu_boxname = freed_qemu_boxname
1227                     if not vnode_hostname:
1228                         vnode_hostname = freed_vnode_hostname
1229                         self.vnode_pool.set_mine(vnode_hostname)
1230
1231             self.add_dummy_qemu(qemu_boxname, vnode_hostname)
1232             mac = self.vnode_pool.retrieve_userdata(vnode_hostname)
1233             ip = self.vnode_pool.get_ip(vnode_hostname)
1234             self.vnode_pool.add_starting(vnode_hostname, qemu_boxname)
1235
1236             vnode_fqdn = self.fqdn(vnode_hostname)
1237             nodemap = {'host_box' : qemu_boxname,
1238                        'node_fields:hostname' : vnode_fqdn,
1239                        'interface_fields:ip' : ip, 
1240                        'ipaddress_fields:ip_addr' : ip, 
1241                        'interface_fields:mac' : mac,
1242                    }
1243             nodemap.update(self.network_settings())
1244             maps.append( (nodename, nodemap) )
1245
1246             utils.header("PROVISION node {} in box {} at IP {} with MAC {}"\
1247                          .format(nodename, qemu_boxname, vnode_hostname, mac))
1248
1249         return test_mapper.map({'node':maps})[0]
1250
1251     def localize_sfa_rspec(self, plc, options):
1252        
1253         plc['sfa']['settings']['SFA_REGISTRY_HOST'] = plc['settings']['PLC_DB_HOST']
1254         plc['sfa']['settings']['SFA_AGGREGATE_HOST'] = plc['settings']['PLC_DB_HOST']
1255         plc['sfa']['settings']['SFA_SM_HOST'] = plc['settings']['PLC_DB_HOST']
1256         plc['sfa']['settings']['SFA_DB_HOST'] = plc['settings']['PLC_DB_HOST']
1257         plc['sfa']['settings']['SFA_PLC_URL'] = 'https://{}:443/PLCAPI/'.format(plc['settings']['PLC_API_HOST'])
1258         return plc
1259
1260     #################### release:
1261     def release(self, options):
1262         self.vplc_pool.release_my_starting()
1263         self.vnode_pool.release_my_starting()
1264         pass
1265
1266     #################### show results for interactive mode
1267     def get_box(self, boxname):
1268         for b in self.build_boxes + self.plc_boxes + self.qemu_boxes + [self.test_box] :
1269             if b.shortname() == boxname:
1270                 return b
1271             try:
1272                 if b.shortname() == boxname.split('.')[0]:
1273                     return b
1274             except:
1275                 pass
1276         print("Could not find box {}".format(boxname))
1277         return None
1278
1279     # deal with the mix of boxes and names and stores the current focus 
1280     # as a list of Box instances in self.focus_all
1281     def normalize(self, box_or_names):
1282         self.focus_all = []
1283         for box in box_or_names:
1284             if not isinstance(box, Box):
1285                 box = self.get_box(box)
1286             if not box: 
1287                 print('Warning - could not handle box',box)
1288             self.focus_all.append(box)
1289         # elaborate by type
1290         self.focus_build = [ x for x in self.focus_all if isinstance(x, BuildBox) ]
1291         self.focus_plc = [ x for x in self.focus_all if isinstance(x, PlcBox) ]
1292         self.focus_qemu = [ x for x in self.focus_all if isinstance(x, QemuBox) ]
1293                              
1294     def list_boxes(self):
1295         print('Sensing', end=' ')
1296         for box in self.focus_all:
1297             box.sense(self.options)
1298         print('Done')
1299         for box in self.focus_all:
1300             box.list(self.options.verbose)
1301
1302     def reboot_boxes(self):
1303         for box in self.focus_all:
1304             box.reboot(self.options)
1305
1306     def sanity_check(self):
1307         print('Sanity check')
1308         self.sanity_check_plc()
1309         self.sanity_check_qemu()
1310
1311     def sanity_check_plc(self):
1312         pass
1313
1314     def sanity_check_qemu(self):
1315         all_nodes = []
1316         for box in self.focus_qemu:
1317             all_nodes += box.node_names()
1318         hash = {}
1319         for node in all_nodes:
1320             if node not in hash:
1321                 hash[node] = 0
1322             hash[node]+=1
1323         for (node,count) in list(hash.items()):
1324             if count!=1:
1325                 print('WARNING - duplicate node', node)
1326         
1327
1328     ####################
1329     # can be run as a utility to probe/display/manage the local infrastructure
1330     def main(self):
1331         parser = OptionParser()
1332         parser.add_option('-r', "--reboot", action='store_true', dest='reboot', default=False,
1333                           help='reboot mode (use shutdown -r)')
1334         parser.add_option('-s', "--soft", action='store_true', dest='soft', default=False,
1335                           help='soft mode for reboot (terminates processes)')
1336         parser.add_option('-t', "--testbox", action='store_true', dest='testbox', default=False,
1337                           help='add test box') 
1338         parser.add_option('-b', "--build", action='store_true', dest='builds', default=False,
1339                           help='add build boxes')
1340         parser.add_option('-p', "--plc", action='store_true', dest='plcs', default=False,
1341                           help='add plc boxes')
1342         parser.add_option('-q', "--qemu", action='store_true', dest='qemus', default=False,
1343                           help='add qemu boxes') 
1344         parser.add_option('-a', "--all", action='store_true', dest='all', default=False,
1345                           help='address all known  boxes, like -b -t -p -q')
1346         parser.add_option('-v', "--verbose", action='store_true', dest='verbose', default=False,
1347                           help='verbose mode')
1348         parser.add_option('-n', "--dry_run", action='store_true', dest='dry_run', default=False,
1349                           help='dry run mode')
1350         (self.options, args) = parser.parse_args()
1351
1352         boxes = args
1353         if self.options.testbox: boxes += [self.test_box]
1354         if self.options.builds: boxes += self.build_boxes
1355         if self.options.plcs: boxes += self.plc_boxes
1356         if self.options.qemus: boxes += self.qemu_boxes
1357         if self.options.all: boxes += self.all_boxes
1358         
1359         global verbose
1360         verbose = self.options.verbose
1361         # default scope is -b -p -q -t
1362         if not boxes:
1363             boxes = self.build_boxes + self.plc_boxes + self.qemu_boxes + [self.test_box]
1364
1365         self.normalize(boxes)
1366
1367         if self.options.reboot:
1368             self.reboot_boxes()
1369         else:
1370             self.list_boxes()
1371             self.sanity_check()