Some tools for auto-probing attached bruno devices.

'make -j20 map' will probe all local serial ports, plus the subnet(s)
defined in NMAP_SUBNETS, for serial-attached or ssh-enabled bruno devices.
It retrieves a bunch of details about each device (CPU type, storage vs. tv
box, nfsroot, IP address, ethernet address, whether ethernet or moca is
plugged in, etc) and stores the details persistently in configs/*.  Then
config.py assembles it all nicely.  Try this:

	make -j20
	python config.py

It dumps a human-readable list of discovered bruno devices to stdout.

The config.hosts array is queryable based on parameters, so for example, you
can request a storage box and a moca-attached TV box.  Next, we need to
support some per-device locking, and to rearrange the testrunner code so it
allocates and locks one or more available devices before running a test on
those devices.  Then we can have fancy parallelism.
diff --git a/.gitignore b/.gitignore
index 2f836aa..62523e1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
 *~
 *.pyc
+/*.stamp
diff --git a/Makefile b/Makefile
index 4922c26..d56c8f3 100644
--- a/Makefile
+++ b/Makefile
@@ -1,32 +1,92 @@
-PORT=/dev/ttyS0
+PORT=/dev/ttyS0  #TODO(apenwarr): don't hardcode this
 PASSWORD=google
-PORTSH=./portsh/portsh $(PORT) -p'$(PASSWORD)'
-.NOTPARALLEL:
+PORTSH=./portsh/portsh -p'$(PASSWORD)'
+NMAP_SUBNETS=192.168.2.0/24  #TODO(apenwarr): auto-read the subnet from eth1?
 
 default: all
 
-all:
-	@echo "Nothing to do."
+all: map.stamp
+
+lint:
+	gpylint *.py configs/core.py
 
 test: install-wvtest
 	wvtest/wvtestrun $(MAKE) runtests
 
-runtests: $(addsuffix .run,$(wildcard [0-9]*.sh [0-9]*.py))
+#TODO(apenwarr): use a smarter allocator.
+# We could enable parallelism by depending on $(addsuffix ...) instead of
+# looping through them one by one.  But then we end up running multiple
+# tests on the same device at once, which would cause conflicts.  We need
+# a smarter allocator first.
+runtests:
+	for d in $(addsuffix .run,$(wildcard [0-9]*.sh [0-9]*.py)); do \
+	  $(MAKE) $$d; \
+	done
 
-install-wvtest:
+install-wvtest: map.stamp
 	tar -cf - *.py *.sh wvtest/ | \
-	$(PORTSH) \
+	$(PORTSH) $(PORT) \
 		'mkdir -p /tmp/tests && cd /tmp/tests && tar -xf -'
 
 %.sh.run: %.sh install-wvtest
 	echo "Testing $<"
-	$(PORTSH) \
+	$(PORTSH) $(PORT) \
 		'cd /tmp/tests && sh ./$<' </dev/null
 
 %.py.run: %.py install-wvtest
 	echo "Testing $<"
-	$(PORTSH) \
+	$(PORTSH) $(PORT) \
 		'cd /tmp/tests && python ./wvtest/wvtest.py $<' </dev/null
 
+# This rule regenerates the map *every* time.  Run it when your node
+# configuration has changed.
+map: serialmap nmap
+	touch map.stamp
+
+# This rule just runs once; 'make map' or delete map.stamp to re-run.
+# Depend on this rule to ensure we have a map, without regenerating it
+# every time we run make.
+map.stamp:
+	$(MAKE) map
+
+nmap:
+	rm -f configs/nmap.*.tmp
+	$(MAKE) $$(nmap -n -p22 -oG - $(NMAP_SUBNETS) | \
+		grep ^Host: | \
+		awk '{printf "configs/nmap.%s.tmp\n", $$2}')
+	cat configs/header configs/nmap.*.tmp >configs/auto_nmap.py.new
+	mv configs/auto_nmap.py.new configs/auto_nmap.py
+
+serialmap:
+	rm -f configs/tty.*.tmp
+	$(MAKE) $$(cd /dev && /bin/ls ttyS*[0-9] ttyUSB*[0-9] | \
+		awk '{printf "configs/tty.%s.tmp\n", $$1}')
+	cat configs/header configs/tty.*.tmp >configs/auto_serial.py.new
+	mv configs/auto_serial.py.new configs/auto_serial.py
+
+
+PROBE_CMD=\
+  PATH=$$PATH:/bin:/usr/bin:/sbin:/usr/sbin; \
+  hnvram -r 1ST_SERIAL_NUMBER -r SERIAL_NO -r MAC_ADDR -r PLATFORM_NAME; \
+  [ -e /tmp/NFS ] && echo IS_NFSROOT=1; \
+  ip addr show br0; \
+  ip addr show eth0; \
+  ip addr show eth1; \
+  cat /proc/cpuinfo
+
+configs/nmap.%.tmp:
+	ssh -oNumberOfPasswordPrompts=0 \
+		root@$* \
+		'echo NET_OK=1; $(PROBE_CMD)' 2>&1 | \
+	  ./parse-probe-data >$@.new
+	mv $@.new $@
+
+configs/tty.%.tmp:
+	$(PORTSH) $* \
+		'echo SERIALPORT=$*; $(PROBE_CMD)' 2>&1 | \
+	  ./parse-probe-data >$@.new
+	mv $@.new $@
+
 clean:
-	rm -f *~ .*~ */*~ */.*~ *.pyc */*.pyc
+	rm -f *~ .*~ */*~ */.*~ *.pyc */*.pyc *.stamp \
+		configs/*.tmp configs/*.new configs/auto_*.py
diff --git a/config.py b/config.py
new file mode 100644
index 0000000..701d05c
--- /dev/null
+++ b/config.py
@@ -0,0 +1,16 @@
+#!/usr/bin/python
+# Copyright 2012 Google Inc. All Rights Reserved.
+#
+"""Test configurations.  Includes a list of auto-probed bruno devices."""
+
+from configs import hosts
+
+__author__ = 'Avery Pennarun (apenwarr@google.com)'
+
+
+def main():
+  print hosts
+
+
+if __name__ == '__main__':
+  main()
diff --git a/configs/.gitignore b/configs/.gitignore
new file mode 100644
index 0000000..f55461c
--- /dev/null
+++ b/configs/.gitignore
@@ -0,0 +1,3 @@
+/auto_*.py
+/*.tmp
+/*.new
diff --git a/configs/__init__.py b/configs/__init__.py
new file mode 100644
index 0000000..3b6954a
--- /dev/null
+++ b/configs/__init__.py
@@ -0,0 +1,18 @@
+from core import hosts
+
+try:
+  import manual
+except ImportError:
+  pass
+
+try:
+  import auto_nmap
+except ImportError:
+  pass
+
+try:
+  import auto_serial
+except ImportError:
+  pass
+
+
diff --git a/configs/core.py b/configs/core.py
new file mode 100644
index 0000000..2dc6b72
--- /dev/null
+++ b/configs/core.py
@@ -0,0 +1,104 @@
+#!/usr/bin/python
+# Copyright 2012 Google Inc. All Rights Reserved.
+#
+"""Base classes for configuration objects."""
+
+__author__ = 'Avery Pennarun (apenwarr@google.com)'
+
+
+class AttrSet(object):
+  """A simple set of key-value pairs represented as a class with members."""
+
+  __slots__ = ()
+
+  def __init__(self, **kwargs):
+    for key in self.__slots__:
+      setattr(self, key, None)
+    self.Set(**kwargs)
+
+  def Set(self, **kwargs):
+    for key, value in kwargs.iteritems():
+      setattr(self, key, value)
+
+  def Match(self, **kwargs):
+    for key, value in kwargs.iteritems():
+      ival = getattr(self, key)
+      # None and False can match each other
+      #gpylint: disable-msg=C6403
+      if ival == False: ival = None
+      if value == False: value = None
+      if ival != value:
+        return False
+    return True
+
+  def _Items(self):
+    for key in self.__slots__:
+      value = getattr(self, key)
+      if value == True:  #gpylint: disable-msg=C6403
+        yield key
+      elif value is not None and value != False:  #gpylint: disable-msg=C6403
+        yield '%s=%r' % (key, value)
+
+  def __repr__(self):
+    return '<%s>' % ','.join(self._Items())
+
+  def __str__(self):
+    return '\n  '.join(['%s:' % self.__class__.__name__]
+                       + list(self._Items()))
+
+
+class Host(AttrSet):
+  __slots__ = (
+      'name',
+      'ether',
+      'ip',
+      'ip6',
+      'cpu',
+      'platform',
+      'serialport',
+      'serialno',
+      'is_canary',
+      'is_alive',
+      'is_alive_net',
+      'is_storage',
+      'is_tv',
+      'is_nfsroot',
+      'has_ether',
+      'has_moca',
+  )
+
+
+class Hosts(list):
+  """A searchable/queryable list of Host objects."""
+
+  def FindAll(self, **kwargs):
+    return [i for i in self if i.Match(**kwargs)]
+
+  def FindOne(self, **kwargs):
+    l = self.FindAll(**kwargs)
+    if l:
+      return l[0]
+
+  def FindOrAdd(self, **kwargs):
+    h = self.FindOne(**kwargs)
+    if not h:
+      h = Host(**kwargs)
+      self.append(h)
+    return h
+
+  def __str__(self):
+    return '\n'.join(str(i) for i in self)
+
+
+hosts = Hosts()
+
+
+def main():
+  print 'hello'
+  hosts.FindOrAdd(ether='11:22:33:44:55:66').Set(name='myhost',
+                                                 ip='1.2.3.4')
+  print hosts
+
+
+if __name__ == '__main__':
+  main()
diff --git a/configs/header b/configs/header
new file mode 100644
index 0000000..2a22f55
--- /dev/null
+++ b/configs/header
@@ -0,0 +1,4 @@
+#
+# AUTO-GENERATED by Makefile: do not edit!
+#
+from core import hosts
diff --git a/configs/manual.py b/configs/manual.py
new file mode 100644
index 0000000..99cf21b
--- /dev/null
+++ b/configs/manual.py
@@ -0,0 +1,26 @@
+# insert manual configuration here
+from core import hosts
+
+
+#
+# Avery's test network in NYC
+#
+
+h = hosts.FindOrAdd(ether='00:03:9a:33:44:66')
+h.name = 't0-1'
+h.is_canary = True
+
+h = hosts.FindOrAdd(ether='42:42:42:42:42:12')
+h.name = 't0-2'
+
+h = hosts.FindOrAdd(ether='42:21:06:66:aa:03')
+h.name = 't0-3'
+
+h = hosts.FindOrAdd(ether='00:03:42:21:03:69')
+h.name = 't0-4'
+
+h = hosts.FindOrAdd(ether='00:1a:11:30:63:77')
+h.name = 'b2-1'
+
+h = hosts.FindOrAdd(ether='00:1a:11:30:5e:72')
+h.name = 't2-3'
diff --git a/parse-probe-data b/parse-probe-data
new file mode 100755
index 0000000..512fdb3
--- /dev/null
+++ b/parse-probe-data
@@ -0,0 +1,74 @@
+#!/usr/bin/python
+# Copyright 2012 Google Inc. All Rights Reserved.
+#
+
+import re
+import sys
+
+
+__author__ = 'Avery Pennarun (apenwarr@google.com)'
+
+
+def main():
+  data = sys.stdin.read().strip()
+  header_comment = re.sub(re.compile(r'^', re.M), '# ', data)
+  elements = []
+  ether = None
+
+  g = re.search(re.compile(r'^(?:1ST_SERIAL_NUMBER=|SERIAL_NO=)(.+)', re.M),
+                data)
+  if g:
+    elements.append('serialno=%r' % g.group(1))
+
+  g = re.search(re.compile(r'^PLATFORM_NAME=(.+)', re.M), data)
+  if g:
+    platform = g.group(1).upper()
+    elements.append('platform=%r' % platform)
+    if platform.startswith('GFMS'):
+      elements.append('is_storage=1')
+    elif platform.startswith('GFHD'):
+      elements.append('is_tv=1')
+
+  if re.search(re.compile(r'^NET_OK=1', re.M), data):
+    elements.append('is_alive=1')
+    elements.append('is_alive_net=1')
+
+  if re.search(re.compile(r'^IS_NFSROOT=1', re.M), data):
+    elements.append('is_nfsroot=1')
+
+  g = re.search(re.compile(r'^SERIALPORT=(.+)', re.M), data)
+  if g:
+    elements.append('is_alive=1')
+    elements.append('serialport=%r' % g.group(1))
+
+  g = re.search(r'inet ([\d\.]+)', data)
+  if g:
+    elements.append('ip=%r' % g.group(1))
+
+  g = re.search(r'inet6 ([a-fA-F0-9:]+)', data)
+  if g:
+    elements.append('ip6=%r' % g.group(1).lower())
+
+  g = re.search(r'link/ether ([a-fA-F0-9:]+)', data)
+  if g:
+    ether = g.group(1)
+    elements.append('ether=%r' % g.group(1).lower())
+
+  if re.search(r'eth0:.*LOWER_UP', data):
+    elements.append('has_ether=1')
+  if re.search(r'eth1:.*LOWER_UP', data):
+    elements.append('has_moca=1')
+
+  g = re.search(r'system type\s*:\s*(\S+)', data)
+  if g:
+    elements.append('cpu=%r' % g.group(1).upper())
+
+  sys.stdout.write('\n%s\n' % header_comment)
+  if elements and ether:
+    sys.stdout.write('h = hosts.FindOrAdd(ether=%r)\n' % ether)
+    for e in elements:
+      sys.stdout.write('h.%s\n' % e)
+
+
+if __name__ == '__main__':
+  main()