Add run and rinstall scripts.

'run' lets you run an arbitrary shell commad on a bunch of configured bruno
devices.  'rinstall' uses the run command to install a new software version
on a bunch of brunos in parallel, if they aren't already running it, and
carefully returns success or failure depending if they all got upgraded
successfully.

Example (upgrade all the configured TV boxes to koala-3):
	./rinstall /path/to/bruno-koala-3.gi $(./config is_tv)

For examples of the run command, read the rinstall script.

Change-Id: I7d0ba4df9525bdc7d8d19f6be378c8307f68b067
diff --git a/configs/core.py b/configs/core.py
index 42e17a1..1f751a1 100644
--- a/configs/core.py
+++ b/configs/core.py
@@ -84,18 +84,24 @@
   def FindAll(self, **kwargs):
     return [i for i in self if i.Match(**kwargs)]
 
-  def FindOne(self, **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)
+    h = self._FindOne(**kwargs)
     if not h:
       h = Host(**kwargs)
       self.append(h)
     if not h.name:
-      h.name = h.ether or h.ip or h.ip6
+      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):
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..b0e5088
--- /dev/null
+++ b/run.py
@@ -0,0 +1,122 @@
+#!/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 prefix with the host's name)
+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):
+  def __init__(self, prefix, fileobj, raw):
+    self.prefix = prefix
+    self.fileobj = fileobj
+    self.raw = raw
+
+  def fileno(self):  #gpylint: disable-msg=C6409
+    return self.fileobj.fileno()
+
+  def Run(self):
+    """Read from this handler and write its results to stdout."""
+    buf = os.read(self.fileobj.fileno(), 65536)
+    if buf:
+      if self.raw:
+        sys.stdout.write(buf)
+      else:
+        if not buf.endswith('\n'):
+          buf += '\n'
+        lines = buf.split('\n')[:-1]
+        for line in lines:
+          print '%s: %s' % (self.prefix, line)
+    return buf
+
+
+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
+    p = subprocess.Popen(argv, env=env, shell=shell,
+                         stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+    procs.append((host, p))
+    handlers.append(Handler(host.name, p.stdout, opt.raw))
+    handlers.append(Handler(host.name, p.stderr, opt.raw))
+
+  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())