1#!/usr/bin/env python
2#
3# This file is Copyright (c) 2010 by the GPSD project
4# BSD terms apply: see the file COPYING in the distribution root for details.
5#
6"""\
7flocktest - shepherd script for the GPSD test flock
8
9usage: flocktest [-c] [-q] [-d subdir] [-k key] -v [-x exclude] [-?]
10
11The -? makes flocktest prints this help and exits.
12
13The -c option dumps flocktest's configuration and exits
14
15The -k mode installs a specified file of ssh public keys on all machines
16
17Otherwise, the remote flockdriver script is executed on each machine.
18
19The -d option passes it a name for the remote test subdirectory
20If you do not specify a subdirectory name, the value of $LOGNAME will be used.
21
22The -q option suppresses CIA notifications
23
24The -v option shows all ssh commands issued, runs flockdriver with -x set
25and causes logs to be echoed even on success.
26
27The -x option specifies a comma-separated list of items that are
28either remote hostnames or architecture tags.  Matching sites are
29excluded.  You may wish to use this to avoid doing remote tests that
30are redundant with your local ones.
31
32Known bug: The -k has no atomicity check.  Running it from two
33flocktest instances concurrently could result in a scrambled keyfile.
34"""
35# This code runs compatibly under Python 2 and 3.x for x >= 2.
36# Preserve this property!
37from __future__ import absolute_import, print_function, division
38
39import os, sys, getopt, socket, threading, time
40
41try:
42    import configparser                  # Python 2
43except ImportError:
44    import ConfigParser as configparser  # Python 3
45
46try:
47    import commands                # Python 2
48except ImportError:
49    import subprocess as commands  # Python 3
50
51flockdriver = '''
52#!/bin/sh
53#
54# flockdriver - conduct regression tests as an agent for a remote flocktest
55#
56# This file was generated at %(date)s.  Do not hand-hack.
57
58quiet=no
59while getopts dq opt
60do
61    case $opt in
62        d) subdir=$2; shift; shift ;;
63        q) quiet=yes; shift ;;
64    esac
65done
66
67# Fully qualified domain name of the repo host.  You can hardwire this
68# to make the script faster. The -f option works under Linux and FreeBSD,
69# but not OpenBSD and NetBSD. But under OpenBSD and NetBSD,
70# hostname without options gives the FQDN.
71if hostname -f >/dev/null 2>&1
72then
73    site=`hostname -f`
74else
75    site=`hostname`
76fi
77
78if [ -f "flockdriver.lock" ]
79then
80    logmessage="A test was already running when you initiated this one."
81    cd $subdir
82else
83    echo "Test begins: "`date`
84
85    echo "Site: $site"
86    echo "Directory: ${PWD}/${subdir}"
87
88    # Check the origin against the repo origin.  If they do not match,
89    # force a re-clone of the repo
90    if [ -d $subdir ]
91    then
92        repo_origin=`(cd $subdir; git config remote.origin.url)`
93        if [ $repo_origin != "%(origin)s" ]
94        then
95            echo "Forced re-clone."
96            rm -fr $subdir
97        fi
98    fi
99
100    # Set up or update the repo
101    if [ ! -d $subdir ]
102    then
103	git clone %(origin)s $subdir
104	cd $subdir
105    else
106	cd $subdir;
107	git pull
108    fi
109
110    # Scripts in the test directory need to be able to run binaries in same
111    PATH="$PATH:."
112
113    # Perform the test
114    if ( %(regression)s )
115    then
116	logmessage="Regression test succeeded."
117	status=0
118    else
119	logmessage="Regression test failed."
120	status=1
121    fi
122
123    echo "Test ends: "`date`
124fi
125
126# Here is where we abuse CIA to do our notifications for us.
127
128# Addresses for the e-mail
129from="FLOCKDRIVER-NOREPLY@${site}"
130to="cia@cia.navi.cx"
131
132# SMTP client to use
133sendmail="sendmail -t -f ${from}"
134
135# Should include all places sendmail is likely to lurk.
136PATH="$PATH:/usr/sbin/"
137
138# Identify what just succeeded or failed
139merged=$(git rev-parse HEAD)
140rev=$(git describe ${merged} 2>/dev/null)
141[ -z ${rev} ] && rev=${merged}
142refname=$(git symbolic-ref HEAD 2>/dev/null)
143refname=${refname##refs/heads/}
144
145# And the git version
146gitver=$(git --version)
147gitver=${gitver##* }
148
149if [ $quiet = no ]
150then
151    ${sendmail} << EOM
152Message-ID: <${merged}.${subdir}.blip@%(project)s>
153From: ${from}
154To: ${to}
155Content-type: text/xml
156Subject: DeliverXML
157
158<message>
159  <generator>
160    <name>%(project)s Remote Test Flock Driver</name>
161    <version>${gitver}</version>
162    <url>${origin}/flockdriver</url>
163  </generator>
164  <source>
165    <project>%(project)s</project>
166    <branch>${refname}@${site}</branch>
167  </source>
168  <timestamp>`date`</timestamp>
169  <body>
170    <commit>
171      <author>${subdir}</author>
172      <revision>${rev}</revision>
173      <log>${logmessage}</log>
174    </commit>
175  </body>
176</message>
177EOM
178fi
179
180exit $status
181# End.
182'''
183
184class FlockThread(threading.Thread):
185    def __init__(self, site, command):
186        threading.Thread.__init__(self)
187        self.site = site
188        self.command = command
189    def run(self):
190        (self.status, self.output) = commands.getstatusoutput(self.command)
191
192class TestSite(object):
193    "Methods for performing tests on a single remote site."
194    def __init__(self, fqdn, config, execute=True):
195        self.fqdn = fqdn
196        self.config = config
197        self.execute = execute
198        self.me = self.config["login"] + "@" + self.fqdn
199    def error(self, msg):
200        "Report an error while executing a remote command."
201        sys.stderr.write("%s: %s\n" % (self.fqdn, msg))
202    def do_remote(self, remote):
203        "Execute a command on a specified remote host."
204        command = "ssh "
205        if "port" in self.config:
206            command += "-p %s " % self.config["port"]
207        command += "%s '%s'" %  (self.me, remote)
208        if self.verbose:
209            print(command)
210        self.thread =  FlockThread(self, command)
211        self.thread.start()
212    def update_keys(self, filename):
213        "Upload a specified file to replace the remote authorized keys."
214        if 'debian.org' in self.me:
215            self.error("updating keys on debian.org machines makes no sense.")
216            return 1
217        command = "scp '%s' %s:~/.ssh/.authorized_keys" % (os.path.expanduser(filename), self.me)
218        if self.verbose:
219            print(command)
220        status = os.system(command)
221        if status:
222            self.error("copy with '%s' failed" % command)
223        return status
224    def do_append(self, filename, string):
225        "Append a line to a specified remote file, in foreground."
226        self.do_remote("echo \"%s\" >>%s" % (string.strip(), filename))
227    def do_flockdriver(self, agent, invocation):
228        "Copy flockdriver to the remote site and run it."
229        self.starttime = time.time()
230        if self.config.get("quiet", "no") == "yes":
231            invocation += " -q"
232        uploader = "ssh -p %s %s 'cat >%s'" \
233                       % (self.config.get("port", "22"), self.me, agent)
234        if self.verbose:
235            print(uploader)
236        ofp = os.popen(uploader, "w")
237        self.config['date'] = time.ctime()
238        ofp.write(flockdriver % self.config)
239        if ofp.close():
240            print("flocktest: agent upload failed", file=sys.stderr)
241        else:
242            self.do_remote(invocation)
243        self.elapsed = time.time() - self.starttime
244
245class TestFlock(object):
246    "Methods for performing parallel tests on a flock of remote sites."
247    ssh_options = "no-port-forwarding,no-X11-forwarding," \
248                 "no-agent-forwarding,no-pty "
249    def __init__(self, sitelist, verbose=False):
250        self.sitelist = sitelist
251        self.verbose = verbose
252    def update_remote(self, filename):
253        "Copy a specified file to the remote home on all machines."
254        for site in self.sitelist:
255            site.update_remote(filename)
256    def do_remotes(self, agent, invocation):
257        "Execute a command on all machines in the flock."
258        slaves = []
259        print("== testing at: %s ==" % flock.listdump())
260        starttime = time.time()
261        for site in self.sitelist:
262            site.do_flockdriver(agent, invocation)
263        for site in sites:
264            site.thread.join()
265        failed = 0
266        for site in sites:
267            if site.thread.status:
268                print("== %s test FAILED in %.2f seconds, status %d ==" % (site.fqdn, site.elapsed, site.thread.status))
269                failed += 1
270                print(site.thread.output)
271            else:
272                print("== %s test succeeded in %.2f seconds ==" % (site.fqdn, site.elapsed))
273                if self.verbose:
274                    print(site.thread.output)
275        elapsed = time.time() - starttime
276        print("== %d tests completed in %.2f seconds: %d failed ==" % (len(sites), elapsed, failed))
277    def exclude(self, exclusions):
278        "Delete matching sites."
279        self.sitelist = [x for x in self.sitelist if x.fqdn not in exclusions and x.config["arch"] not in exclusions]
280    def update_keys(self, keyfile):
281        "Copy the specified public key file to all sites."
282        for site in self.sitelist:
283            site.update_keys(keyfile)
284    def listdump(self):
285        "Return a dump of the site list."
286        return ", ".join([x.fqdn for x in self.sitelist])
287
288if __name__ == '__main__':
289    try:
290        (options, arguments) = getopt.getopt(sys.argv[1:], "cd:kqvx:?")
291    except getopt.GetoptError as msg:
292        print("flocktest: " + str(msg))
293        raise SystemExit(1)
294
295    exclusions = []
296    subdir = None
297    copykeys = None
298    verbose = False
299    dumpconf = False
300    cianotify = True
301    for (switch, val) in options:
302        if  switch == '-c':	# Dump flocktest configuration
303            dumpconf = True
304        elif switch == '-d':	# Set the test subdirectory name
305            subdir = val
306        elif  switch == '-k':	# Install the access keys
307            copykeys = True
308        elif switch == '-q':	# Suppress CIA notifications
309            cianotify = False
310        elif switch == '-v':	# Display build log even when no error
311            verbose = True
312        elif switch == '-x':	# Exclude specified sites or architectures
313            exclusions = [x.strip() for x in val.split(",")]
314        else: # switch == '-?':
315            print(__doc__)
316            sys.exit(0)
317
318    config = configparser.RawConfigParser()
319    config.read(["flocktest.ini", ".flocktest.ini"])
320    if arguments:
321        config.set("DEFAULT", "origin", arguments[0])
322    if not config.has_option("DEFAULT", "origin"):
323        print("flocktest: repository required.", file=sys.stderr)
324        sys.exit(1)
325    sites = []
326    for site in config.sections():
327        newsite = TestSite(site, dict(config.items(site)))
328        newsite.verbose = verbose
329        if newsite.config["status"].lower() == "up":
330            sites.append(newsite)
331    flock = TestFlock(sites, verbose)
332    if exclusions:
333        flock.exclude(exclusions)
334    if dumpconf:
335        config.write(sys.stdout)
336    elif copykeys:
337        keyfile = config.get("DEFAULT", "sshkeys")
338        flock.update_keys(keyfile)
339    else:
340        if not subdir:
341            subdir = os.getenv("LOGNAME")
342        if not subdir:
343            print("flocktest: you don't exist, go away!")
344            sys.exit(1)
345        agent = "flockdriver.%s" % subdir
346        invocation = "sh flockdriver.%s -d %s" % (subdir, subdir,)
347        if not cianotify:
348            invocation += " -q"
349        if verbose > 1:
350            invocation = "sh -x " + invocation
351        flock.do_remotes(agent, invocation)
352
353# The following sets edit modes for GNU EMACS
354# Local Variables:
355# mode:python
356# End:
357