Merge changes I93e64654,I79f06f46

* changes:
  Bruno automation test cases code upload: new test cases completed: some networking test cases and basic TR069 test cases.
  Download/DataModel Test case code upload
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..e3d52a3 100644
--- a/Makefile
+++ b/Makefile
@@ -1,32 +1,97 @@
-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
 
-test: install-wvtest
+lint:
+	gpylint *.py configs/core.py
+
+test:
 	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: localtests install-wvtest
+	for d in $(addsuffix .run,$(wildcard [0-9]*.sh [0-9]*.py)); do \
+	  $(MAKE) $$d; \
+	done
 
-install-wvtest:
-	tar -cf - *.py *.sh wvtest/ | \
-	$(PORTSH) \
+localtests:
+	./wvtest/wvtest.py *_test.py configs/*_test.py
+
+install-wvtest: map.stamp *.py *.sh wvtest/*
+	@echo 'Testing "install-wvtest" in Makefile:'
+	tar -cf - $^ | \
+	$(PORTSH) $(PORT) \
 		'mkdir -p /tmp/tests && cd /tmp/tests && tar -xf -'
 
-%.sh.run: %.sh install-wvtest
+%.sh.run: %.sh
 	echo "Testing $<"
-	$(PORTSH) \
+	$(PORTSH) $(PORT) \
 		'cd /tmp/tests && sh ./$<' </dev/null
 
-%.py.run: %.py install-wvtest
+%.py.run: %.py
 	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; \
+  ip addr show eth2; \
+  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/checkall b/checkall
new file mode 100755
index 0000000..bd5aa40
--- /dev/null
+++ b/checkall
@@ -0,0 +1,20 @@
+#!/bin/sh
+#
+# Display a one-line overview of whether each device is up.
+#
+mydir=$(dirname "$0")
+cd "$mydir"
+hosts=$(./config)
+
+out=
+./run $hosts -- 'ping -c1 -w1 -n $ip >/dev/null && echo yes || echo no' |
+sort | (
+  while read name ok; do
+    case "$ok" in
+      yes) out="$out${name%:}  " ;;
+      no)  out="$out^${name%:} " ;;
+      *)   ( echo; echo "Unexpected: '$name' '$ok'" ) >&2 ;;
+    esac
+  done
+  echo "$out"
+)
diff --git a/config b/config
new file mode 120000
index 0000000..f85c6b1
--- /dev/null
+++ b/config
@@ -0,0 +1 @@
+config.py
\ No newline at end of file
diff --git a/config.py b/config.py
new file mode 100755
index 0000000..9f65977
--- /dev/null
+++ b/config.py
@@ -0,0 +1,70 @@
+#!/usr/bin/python
+# Copyright 2012 Google Inc. All Rights Reserved.
+#
+"""Test configurations.  Includes a list of auto-probed bruno devices."""
+
+import re
+import sys
+from configs import hosts
+from configs.core import Host
+from configs.core import QueryError
+from portsh import options
+
+__author__ = 'Avery Pennarun (apenwarr@google.com)'
+
+
+optspec = """
+config [options...] <bool|^bool|key=value>...
+--
+random     Choose random matches instead of the first ones
+max=       Return no more than max= matches
+min=       If less than min= matches, return an error code
+n,num=     Return exactly num= matches
+v,verbose  Display the entire config entry for each match, not just the name
+k,keys=    If -v isn't given, show these comma/space-separated attrs [name]
+"""
+
+
+def main():
+  o = options.Options(optspec)
+  opt, _, extra = o.parse(sys.argv[1:])
+  if opt.num:
+    if opt.min or opt.max:
+      o.fatal("don't specify --num with --min/--max")
+    opt.min = opt.max = opt.num
+
+  kwargs = dict(is_alive=True)
+  for kvs in extra:
+    kv = kvs.split('=', 1) + [None]
+    key = kv[0]
+    value = kv[1]
+    if not hasattr(Host, key):
+      o.fatal('no key named %r; valid keys: %s'
+              % (key, ', '.join(Host.__slots__)))
+      sys.exit(1)
+    if key.startswith('^'):
+      kwargs[key[1:]] = False
+    elif value is not None:
+      kwargs[key] = value
+    else:
+      kwargs[key] = True
+  try:
+    matches = hosts.Query(mincount=opt.min, maxcount=opt.max,
+                          randomize=opt.random, **kwargs)
+  except QueryError, e:
+    o.fatal(str(e))
+  if opt.verbose:
+    for match in matches:
+      print match
+  else:
+    keys = re.split(r'[,\s]', opt.keys)
+    for key in keys:
+      if not hasattr(Host, key):
+        o.fatal('no key named %r; valid keys: %s'
+                % (key, ', '.join(Host.__slots__)))
+    for match in matches:
+      print ','.join(str(getattr(match, key) or '') for key in keys)
+
+
+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..f85e2fa
--- /dev/null
+++ b/configs/core.py
@@ -0,0 +1,151 @@
+#!/usr/bin/python
+# Copyright 2012 Google Inc. All Rights Reserved.
+#
+"""Base classes for configuration objects."""
+
+__author__ = 'Avery Pennarun (apenwarr@google.com)'
+
+
+import random
+
+
+class QueryError(Exception):
+  pass
+
+
+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 value == True and ival:
+        continue  # any nonempty value is true, so match it
+      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',
+      'has_wifi',
+  )
+
+
+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 FindOne(self, **kwargs):
+    v = self._FindOne(**kwargs)
+    if v is None:
+      raise KeyError(kwargs)
+    return v
+
+  def FindOrAdd(self, **kwargs):
+    h = self._FindOne(**kwargs)
+    if not h:
+      h = Host(**kwargs)
+      self.append(h)
+    if not h.name:
+      h.name = h.ether or h.serialno or h.ip or h.ip6
+    return h
+
+  def Query(self, mincount=None, maxcount=None, randomize=False, **kwargs):
+    """Query the hosts list according to the given parameters.
+
+    Args:
+      mincount: the minimum number of hosts to return.
+      maxcount: the maximum number of hosts to return.
+      randomize: return a random subset or the first maxcount matches.
+      **kwargs: key=value pairs to pass to hosts.FindAll().
+    Returns:
+      A list of matching Host objects.
+    Raises:
+      QueryError: if mincount > maxcount or other invalid options.
+    """
+    matches = self.FindAll(**kwargs)
+    if maxcount and maxcount < 0:
+      raise QueryError('max(%s) must be >= 0' % maxcount)
+    if maxcount and mincount > maxcount:
+      raise QueryError('min(%s) > max(%s)' % (mincount, maxcount))
+    if mincount > len(matches):
+      raise QueryError('not enough matches found (got=%d, wanted=%r)'
+                       % (len(matches), mincount))
+    if randomize:
+      random.shuffle(matches)
+    else:
+      matches.sort(key=lambda i: (i.name, i.serialno, i.ether, i.ip6, i.ip))
+    if maxcount is not None and len(matches) > maxcount:
+      matches = matches[:maxcount]
+    return matches
+
+  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/core_test.py b/configs/core_test.py
new file mode 100644
index 0000000..21b1f5f
--- /dev/null
+++ b/configs/core_test.py
@@ -0,0 +1,46 @@
+#!/usr/bin/python
+# Copyright 2012 Google Inc. All Rights Reserved.
+#
+# Disable some checks that aren't important for tests:
+#gpylint: disable-msg=E0602,C6409,C6111,C0111
+
+__author__ = 'Avery Pennarun (apenwarr@google.com)'
+
+from wvtest import *
+import core
+
+
+@wvtest
+def testConfigCore():
+  hosts = core.Hosts()
+  WVPASSEQ(len(hosts), 0)
+  h1 = hosts.FindOrAdd(ether='11:22:33:44:55:66')
+  h1.Set(name='testname', ip='1.2.3.4')
+  h1.platform = 'bob'
+  WVPASSEQ(len(hosts), 1)
+  h1b = hosts.FindOrAdd(ip='1.2.3.4')
+  WVPASSEQ(len(hosts), 1)
+  WVPASSEQ(h1b.name, 'testname')
+  WVPASSEQ(h1b.ether, '11:22:33:44:55:66')
+  WVPASSEQ(h1, h1b)
+
+  # test Host.Query()
+  WVPASSEQ(hosts.Query(ip=True), [h1])
+  WVPASSEQ(hosts.Query(ip=False), [])
+  WVPASSEQ(hosts.Query(ip='1.2.3.4'), [h1])
+  WVPASSEQ(hosts.Query(ip='1.2.3.5'), [])
+  WVPASSEQ(hosts.Query(ip='1.2.3.4', maxcount=0), [])
+  WVEXCEPT(core.QueryError, hosts.Query, ip='1.2.3.5', mincount=1)
+
+  # add a second host
+  h2 = hosts.FindOrAdd(ether='22:33:44:22:33:44')
+  h2.Set(name='test2')
+
+  WVPASSEQ(len(hosts), 2)
+  WVPASSEQ(hosts.Query(ip='1.2.3.4'), [h1])
+  WVPASSEQ(hosts.Query(ip=None), [h2])
+  WVPASSEQ(hosts.Query(name='test2'), [h2])
+  WVPASSEQ(hosts.Query(ip=True), [h1])
+  WVPASSEQ(sorted(hosts.Query(name=True)), sorted([h1, h2]))
+  WVPASSEQ(hosts.Query(ether=True, name='test2'), [h2])
+  WVPASSEQ(hosts.Query(ether=True, name='testname'), [h1])
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..d3ec307
--- /dev/null
+++ b/parse-probe-data
@@ -0,0 +1,76 @@
+#!/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')
+  if re.search(r'eth2:.*LOWER_UP', data):
+    elements.append('has_wifi=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()
diff --git a/portsh/__init__.py b/portsh/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/portsh/__init__.py
diff --git a/rinstall b/rinstall
new file mode 100755
index 0000000..906aac7
--- /dev/null
+++ b/rinstall
@@ -0,0 +1,90 @@
+#!/bin/sh
+#
+# Upgrade multiple bruno devices at once.  Returns 0 if they were all
+# successful.
+#
+mydir=$(dirname "$0")
+imgfile=$1
+imgbase=$(basename "$imgfile" .gi)
+shift
+hosts="$*"
+
+log()
+{
+  printf '\x1b[1m%s\x1b[m\n' "$*" >&2
+}
+
+
+die()
+{
+  log "FAILED"
+  exit 1
+}
+
+
+run()
+{
+  "$mydir/run" $hosts -- "$@" || die
+}
+
+
+if [ ! -e "$imgfile" ] || [ -z "$hosts" ]; then
+  log "Usage: $0 <filename.gi> [hostnames...]"
+  exit 1
+fi
+
+log "Checking running versions..."
+versions=$(run ssh root@\$ip cat /etc/version) || die
+echo "$versions"
+newhosts=$(
+  echo "$versions" |
+  while IFS=': ' read name version; do
+    if [ "$version" != "$imgbase" ]; then
+      echo "$name"
+    else
+      log "'$name' is up to date; skipping."
+    fi
+  done
+)
+if [ -z "$newhosts" ]; then
+  log "No upgrades needed."
+  exit 0
+fi
+log "Will upgrade:" $newhosts
+hosts=$newhosts
+
+log "Copying image files..."
+imgfile=$imgfile run 'scp "$imgfile" root@$ip:/tmp/img.gi && echo ok'
+
+log "Installing..."
+run 'ssh root@$ip ginstall -t /tmp/img.gi -p other && echo ok'
+
+log "Rebooting..."
+run ssh root@\$ip '(sleep 2; reboot) &'
+sleep 5
+
+log "Waiting for hosts to come back up..."
+run '
+  for i in $(seq 5 5 90); do
+    if ping -c1 -w5 $ip >/dev/null; then
+      echo "ok!"
+      sleep 5
+      exit 0
+    fi
+  done
+  exit 1
+'
+
+log "Checking running versions (expect: $imgbase)..."
+versions=$(run ssh root@\$ip cat /etc/version) || die
+echo "$versions"
+echo "$versions" | (
+  rv=0
+  while IFS=': ' read name version; do
+    if [ "$version" != "$imgbase" ]; then
+      log "$name: failed to upgrade!"
+      rv=1
+    fi
+  done
+  exit $rv
+) || die
diff --git a/run b/run
new file mode 120000
index 0000000..e56fbdd
--- /dev/null
+++ b/run
@@ -0,0 +1 @@
+run.py
\ No newline at end of file
diff --git a/run.py b/run.py
new file mode 100755
index 0000000..1130dc7
--- /dev/null
+++ b/run.py
@@ -0,0 +1,130 @@
+#!/usr/bin/python
+# Copyright 2012 Google Inc. All Rights Reserved.
+#
+"""Run the given shell command for each of the given hosts."""
+
+import os
+import re
+import select
+import subprocess
+import sys
+import config
+from portsh import options
+
+__author__ = 'Avery Pennarun (apenwarr@google.com)'
+
+
+optspec = """
+run [options...] <hosts> -- <command string...>
+--
+r,raw           Raw output (don't redirect stdin/stdout/stderr)
+q,quiet         Quiet: don't print to stderr for nonzero exit codes
+"""
+
+
+def _FixNull(s):
+  if s is None:
+    return ''
+  return str(s)
+
+
+def _ArgSub(argv, host):
+  for arg in argv:
+    argout = []
+    for part in re.split(r'(\$\w+)', arg):
+      if part.startswith('$'):
+        for var in config.Host.__slots__:
+          if part[1:] == var:
+            part = _FixNull(getattr(host, var))
+            break
+      argout.append(part)
+    yield ''.join(argout)
+
+
+class Handler(object):
+  """When data is received from the given file, print it line by line."""
+
+  def __init__(self, prefix, fileobj):
+    self.prefix = prefix
+    self.fileobj = fileobj
+    self.buf = ''
+
+  def __del__(self):
+    if self.buf and not self.buf.endswith('\n'):
+      self.buf += '\n'
+    self.PrintBuf()
+
+  def fileno(self):  #gpylint: disable-msg=C6409
+    return self.fileobj.fileno()
+
+  def PrintBuf(self):
+    while '\n' in self.buf:
+      line, self.buf = self.buf.split('\n', 1)
+      print '%s: %s' % (self.prefix, line)
+
+  def Run(self):
+    """Read from this handler and write its results to stdout."""
+    got = os.read(self.fileobj.fileno(), 65536)
+    self.buf += got
+    self.PrintBuf()
+    return got
+
+
+def main():
+  o = options.Options(optspec)
+  args = []
+  cmd = []
+  for i in range(1, len(sys.argv)):
+    if sys.argv[i] == '--':
+      args = sys.argv[1:i]
+      cmd = sys.argv[i+1:]
+      break
+  opt, _, hostnames = o.parse(args)
+  if not hostnames or not cmd:
+    o.fatal('you must specify hosts, --, and a command string')
+
+  try:
+    hosts = [config.hosts.FindOne(name=i) for i in hostnames]
+  except KeyError, e:
+    o.fatal('host not configured: %s' % e)
+
+  procs = []
+  handlers = []
+  for host in hosts:
+    env = dict(os.environ)
+    env.update(dict((key, _FixNull(getattr(host, key)))
+                    for key in config.Host.__slots__))
+    if len(cmd) > 1:
+      argv = list(_ArgSub(cmd, host))
+      shell = False
+    else:
+      argv = cmd[0]
+      shell = True
+    if opt.raw:
+      p = subprocess.Popen(argv, env=env, shell=shell)
+    else:
+      p = subprocess.Popen(argv, env=env, shell=shell,
+                           stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+      handlers.append(Handler(host.name, p.stdout))
+      handlers.append(Handler(host.name, p.stderr))
+    procs.append((host, p))
+
+  while handlers:
+    r, _, _ = select.select(handlers, [], [])
+    for handler in r:
+      if not handler.Run():
+        handlers.remove(handler)
+
+  final_rv = 0
+  for host, p in procs:
+    rv = p.wait()
+    if rv != 0:
+      if not opt.quiet:
+        sys.stderr.write('-- %s: exited with error code %d\n'
+                         % (host.name, rv))
+      final_rv = 1
+  return final_rv
+
+
+if __name__ == '__main__':
+  sys.exit(main())
diff --git a/sshto b/sshto
new file mode 100755
index 0000000..8c4f7e2
--- /dev/null
+++ b/sshto
@@ -0,0 +1,44 @@
+#!/bin/sh
+#
+# ssh (or, if no network, portsh) into the given device.
+#
+mydir=$(dirname "$0")
+cd "$mydir"
+
+name=$1
+shift
+
+log()
+{
+  echo "$@" >&2
+}
+
+
+die()
+{
+  log "$@"
+  exit 99
+}
+
+
+if [ -z "$name" ]; then
+  die "Usage: $0 <configname> [command...]"
+fi
+
+cmd=$(args="$*" ./run --quiet --raw "$name" -- '
+  if [ -n "$ip6" ] && ping6 -n -c1 -w2 $ip6 >/dev/null 2>&1; then
+    echo "ssh root@$ip6"
+  elif [ -n "$ip" ] && ping -n -c1 -w2 $ip >/dev/null 2>&1; then
+    echo "ssh root@$ip"
+  elif [ -n "$serialport" ]; then
+    if [ -n "$args" ]; then
+      echo "./portsh/portsh -pgoogle $serialport"
+    else
+      echo "./portsh/port $serialport"
+    fi
+  else
+    exit 1
+  fi
+') || die "Fatal: host '$name' has no live IP or serial port"
+log ">>> $cmd --" "$@"
+exec $cmd -- "$@"