diff options
Diffstat (limited to 'pytest/client.py')
| -rwxr-xr-x | pytest/client.py | 643 |
1 files changed, 643 insertions, 0 deletions
diff --git a/pytest/client.py b/pytest/client.py new file mode 100755 index 000000000000..1746d4f6e277 --- /dev/null +++ b/pytest/client.py @@ -0,0 +1,643 @@ +#! /usr/bin/env python + +""" +Run various tests, as a client. +""" + +from __future__ import print_function + +import argparse +try: + import ConfigParser as configparser +except ImportError: + import configparser +import functools +import logging +import os +import socket +import struct +import sys +import time +import traceback + +import p9conn +import protocol + +LocalError = p9conn.LocalError +RemoteError = p9conn.RemoteError +TEError = p9conn.TEError + +class TestState(object): + def __init__(self): + self.config = None + self.logger = None + self.successes = 0 + self.skips = 0 + self.failures = 0 + self.exceptions = 0 + self.clnt_tab = {} + self.mkclient = None + self.stop = False + self.gid = 0 + + def ccc(self, cid=None): + """ + Connect or reconnect as client (ccc = check and connect client). + + If caller provides a cid (client ID) we check that specific + client. Otherwise the default ID ('base') is used. + In any case we return the now-connected client, plus the + attachment (session info) if any. + """ + if cid is None: + cid = 'base' + pair = self.clnt_tab.get(cid) + if pair is None: + clnt = self.mkclient() + pair = [clnt, None] + self.clnt_tab[cid] = pair + else: + clnt = pair[0] + if not clnt.is_connected(): + clnt.connect() + return pair + + def dcc(self, cid=None): + """ + Disconnect client (disconnect checked client). If no specific + client ID is provided, this disconnects ALL checked clients! + """ + if cid is None: + for cid in list(self.clnt_tab.keys()): + self.dcc(cid) + pair = self.clnt_tab.get(cid) + if pair is not None: + clnt = pair[0] + if clnt.is_connected(): + clnt.shutdown() + del self.clnt_tab[cid] + + def ccs(self, cid=None): + """ + Like ccc, but establish a session as well, by setting up + the uname/n_uname. + + Return the client instance (only). + """ + pair = self.ccc(cid) + clnt = pair[0] + if pair[1] is None: + # No session yet - establish one. Note, this may fail. + section = None if cid is None else ('client-' + cid) + aname = getconf(self.config, section, 'aname', '') + uname = getconf(self.config, section, 'uname', '') + if clnt.proto > protocol.plain: + n_uname = getint(self.config, section, 'n_uname', 1001) + else: + n_uname = None + clnt.attach(afid=None, aname=aname, uname=uname, n_uname=n_uname) + pair[1] = (aname, uname, n_uname) + return clnt + +def getconf(conf, section, name, default=None, rtype=str): + """ + Get configuration item for given section, or for "client" if + there is no entry for that particular section (or if section + is None). + + This lets us get specific values for specific tests or + groups ([foo] name=value), falling back to general values + ([client] name=value). + + The type of the returned value <rtype> can be str, int, bool, + or float. The default is str (and see getconfint, getconfbool, + getconffloat below). + + A default value may be supplied; if it is, that's the default + return value (this default should have the right type). If + no default is supplied, a missing value is an error. + """ + try: + # note: conf.get(None, 'foo') raises NoSectionError + where = section + result = conf.get(where, name) + except (configparser.NoSectionError, configparser.NoOptionError): + try: + where = 'client' + result = conf.get(where, name) + except configparser.NoSectionError: + sys.exit('no [{0}] section in configuration!'.format(where)) + except configparser.NoOptionError: + if default is not None: + return default + if section is not None: + where = '[{0}] or [{1}]'.format(section, where) + else: + where = '[{0}]'.format(where) + raise LocalError('need {0}=value in {1}'.format(name, where)) + where = '[{0}]'.format(where) + if rtype is str: + return result + if rtype is int: + return int(result) + if rtype is float: + return float(result) + if rtype is bool: + if result.lower() in ('1', 't', 'true', 'y', 'yes'): + return True + if result.lower() in ('0', 'f', 'false', 'n', 'no'): + return False + raise ValueError('{0} {1}={2}: invalid boolean'.format(where, name, + result)) + raise ValueError('{0} {1}={2}: internal error: bad result type ' + '{3!r}'.format(where, name, result, rtype)) + +def getint(conf, section, name, default=None): + "get integer config item" + return getconf(conf, section, name, default, int) + +def getfloat(conf, section, name, default=None): + "get float config item" + return getconf(conf, section, name, default, float) + +def getbool(conf, section, name, default=None): + "get boolean config item" + return getconf(conf, section, name, default, bool) + +def pluralize(n, singular, plural): + "return singular or plural based on value of n" + return plural if n != 1 else singular + +class TCDone(Exception): + "used in succ/fail/skip - skips rest of testcase with" + pass + +class TestCase(object): + """ + Start a test case. Most callers must then do a ccs() to connect. + + A failed test will generally disconnect from the server; a + new ccs() will reconnect, if the server is still alive. + """ + def __init__(self, name, tstate): + self.name = name + self.status = None + self.detail = None + self.tstate = tstate + self._shutdown = None + self._autoclunk = None + self._acconn = None + + def auto_disconnect(self, conn): + self._shutdown = conn + + def succ(self, detail=None): + "set success status" + self.status = 'SUCC' + self.detail = detail + raise TCDone() + + def fail(self, detail): + "set failure status" + self.status = 'FAIL' + self.detail = detail + raise TCDone() + + def skip(self, detail=None): + "set skip status" + self.status = 'SKIP' + self.detail = detail + raise TCDone() + + def autoclunk(self, fid): + "mark fid to be closed/clunked on test exit" + if self._acconn is None: + raise ValueError('autoclunk: no _acconn') + self._autoclunk.append(fid) + + def trace(self, msg, *args, **kwargs): + "add tracing info to log-file output" + level = kwargs.pop('level', logging.INFO) + self.tstate.logger.log(level, ' ' + msg, *args, **kwargs) + + def ccs(self): + "call tstate ccs, turn socket.error connect failure into test fail" + try: + self.detail = 'connecting' + ret = self.tstate.ccs() + self.detail = None + self._acconn = ret + return ret + except socket.error as err: + self.fail(str(err)) + + def __enter__(self): + self.tstate.logger.log(logging.DEBUG, 'ENTER: %s', self.name) + self._autoclunk = [] + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + tstate = self.tstate + eat_exc = False + tb_detail = None + if exc_type is TCDone: + # we exited with succ, fail, or skip + eat_exc = True + exc_type = None + if exc_type is not None: + if self.status is None: + self.status = 'EXCP' + else: + self.status += ' EXC' + if exc_type == TEError: + # timeout/eof - best guess is that we crashed the server! + eat_exc = True + tb_detail = ['timeout or EOF'] + elif exc_type in (socket.error, RemoteError, LocalError): + eat_exc = True + tb_detail = traceback.format_exception(exc_type, exc_val, + exc_tb) + level = logging.ERROR + tstate.failures += 1 + tstate.exceptions += 1 + else: + if self.status is None: + self.status = 'SUCC' + if self.status == 'SUCC': + level = logging.INFO + tstate.successes += 1 + elif self.status == 'SKIP': + level = logging.INFO + tstate.skips += 1 + else: + level = logging.ERROR + tstate.failures += 1 + tstate.logger.log(level, '%s: %s', self.status, self.name) + if self.detail: + tstate.logger.log(level, ' detail: %s', self.detail) + if tb_detail: + for line in tb_detail: + tstate.logger.log(level, ' %s', line.rstrip()) + for fid in self._autoclunk: + self._acconn.clunk(fid, ignore_error=True) + if self._shutdown: + self._shutdown.shutdown() + return eat_exc + +def main(): + "the usual main" + parser = argparse.ArgumentParser(description='run tests against a server') + + parser.add_argument('-c', '--config', + action='append', + help='specify additional file(s) to read (beyond testconf.ini)') + + args = parser.parse_args() + config = configparser.SafeConfigParser() + # use case sensitive keys + config.optionxform = str + + try: + with open('testconf.ini', 'r') as stream: + config.readfp(stream) + except (OSError, IOError) as err: + sys.exit(str(err)) + if args.config: + ok = config.read(args.config) + failed = set(ok) - set(args.config) + if len(failed): + nfailed = len(failed) + word = 'files' if nfailed > 1 else 'file' + failed = ', '.join(failed) + print('failed to read {0} {1}: {2}'.format(nfailed, word, failed)) + sys.exit(1) + + logging.basicConfig(level=config.get('client', 'loglevel').upper()) + logger = logging.getLogger(__name__) + tstate = TestState() + tstate.logger = logger + tstate.config = config + + server = config.get('client', 'server') + port = config.getint('client', 'port') + proto = config.get('client', 'protocol') + may_downgrade = config.getboolean('client', 'may_downgrade') + timeout = config.getfloat('client', 'timeout') + + tstate.stop = True # unless overwritten below + with TestCase('send bad packet', tstate) as tc: + tc.detail = 'connecting to {0}:{1}'.format(server, port) + try: + conn = p9conn.P9SockIO(logger, server=server, port=port) + except socket.error as err: + tc.fail('cannot connect at all (server down?)') + tc.auto_disconnect(conn) + tc.detail = None + pkt = struct.pack('<I', 256); + conn.write(pkt) + # ignore reply if any, we're just trying to trip the server + tstate.stop = False + tc.succ() + + if not tstate.stop: + tstate.mkclient = functools.partial(p9conn.P9Client, logger, + timeout, proto, may_downgrade, + server=server, port=port) + tstate.stop = True + with TestCase('send bad Tversion', tstate) as tc: + try: + clnt = tstate.mkclient() + except socket.error as err: + tc.fail('can no longer connect, did bad pkt crash server?') + tc.auto_disconnect(clnt) + clnt.set_monkey('version', b'wrongo, fishbreath!') + tc.detail = 'connecting' + try: + clnt.connect() + except RemoteError as err: + tstate.stop = False + tc.succ(err.args[0]) + tc.fail('server accepted a bad Tversion') + + if not tstate.stop: + # All NUL characters in strings are invalid. + with TestCase('send illegal NUL in Tversion', tstate) as tc: + clnt = tstate.mkclient() + tc.auto_disconnect(clnt) + clnt.set_monkey('version', b'9P2000\0') + # Forcibly allow downgrade so that Tversion + # succeeds if they ignore the \0. + clnt.may_downgrade = True + tc.detail = 'connecting' + try: + clnt.connect() + except (TEError, RemoteError) as err: + tc.succ(err.args[0]) + tc.fail('server accepted NUL in Tversion') + + if not tstate.stop: + with TestCase('connect normally', tstate) as tc: + tc.detail = 'connecting' + try: + tstate.ccc() + except RemoteError as err: + # can't test any further, but this might be success + tstate.stop = True + if 'they only support version' in err.args[0]: + tc.succ(err.args[0]) + tc.fail(err.args[0]) + tc.succ() + + if not tstate.stop: + with TestCase('attach with bad afid', tstate) as tc: + clnt = tstate.ccc()[0] + section = 'attach-with-bad-afid' + aname = getconf(tstate.config, section, 'aname', '') + uname = getconf(tstate.config, section, 'uname', '') + if clnt.proto > protocol.plain: + n_uname = getint(tstate.config, section, 'n_uname', 1001) + else: + n_uname = None + try: + clnt.attach(afid=42, aname=aname, uname=uname, n_uname=n_uname) + except RemoteError as err: + tc.succ(err.args[0]) + tc.dcc() + tc.fail('bad attach afid not rejected') + + try: + if not tstate.stop: + # Various Linux tests need gids. Just get them for everyone. + tstate.gid = getint(tstate.config, 'client', 'gid', 0) + more_test_cases(tstate) + finally: + tstate.dcc() + + n_tests = tstate.successes + tstate.failures + print('summary:') + if tstate.successes: + print('{0}/{1} tests succeeded'.format(tstate.successes, n_tests)) + if tstate.failures: + print('{0}/{1} tests failed'.format(tstate.failures, n_tests)) + if tstate.skips: + print('{0} {1} skipped'.format(tstate.skips, + pluralize(tstate.skips, + 'test', 'tests'))) + if tstate.exceptions: + print('{0} {1} occurred'.format(tstate.exceptions, + pluralize(tstate.exceptions, + 'exception', 'exceptions'))) + if tstate.stop: + print('tests stopped early') + return 1 if tstate.stop or tstate.exceptions or tstate.failures else 0 + +def more_test_cases(tstate): + "run cases that can only proceed if connecting works at all" + with TestCase('attach normally', tstate) as tc: + tc.ccs() + tc.succ() + if tstate.stop: + return + + # Empty string is not technically illegal. It's not clear + # whether it should be accepted or rejected. However, it + # used to crash the server entirely, so it's a desirable + # test case. + with TestCase('empty string in Twalk request', tstate) as tc: + clnt = tc.ccs() + try: + fid, qid = clnt.lookup(clnt.rootfid, [b'']) + except RemoteError as err: + tc.succ(err.args[0]) + clnt.clunk(fid) + tc.succ('note: empty Twalk component name not rejected') + + # Name components may not contain / + with TestCase('embedded / in lookup component name', tstate) as tc: + clnt = tc.ccs() + try: + fid, qid = clnt.lookup(clnt.rootfid, [b'/']) + tc.autoclunk(fid) + except RemoteError as err: + tc.succ(err.args[0]) + tc.fail('/ in lookup component name not rejected') + + # Proceed from a clean tree. As a side effect, this also tests + # either the old style readdir (read() on a directory fid) or + # the dot-L readdir(). + # + # The test case will fail if we don't have permission to remove + # some file(s). + with TestCase('clean up tree (readdir+remove)', tstate) as tc: + clnt = tc.ccs() + fset = clnt.uxreaddir(b'/') + fset = [i for i in fset if i != '.' and i != '..'] + tc.trace("what's there initially: {0!r}".format(fset)) + try: + clnt.uxremove(b'/', force=False, recurse=True) + except RemoteError as err: + tc.trace('failed to read or clean up tree', level=logging.ERROR) + tc.trace('this might be a permissions error', level=logging.ERROR) + tstate.stop = True + tc.fail(str(err)) + fset = clnt.uxreaddir(b'/') + fset = [i for i in fset if i != '.' and i != '..'] + tc.trace("what's left after removing everything: {0!r}".format(fset)) + if fset: + tstate.stop = True + tc.trace('note: could be a permissions error', level=logging.ERROR) + tc.fail('/ not empty after removing all: {0!r}'.format(fset)) + tc.succ() + if tstate.stop: + return + + # Name supplied to create, mkdir, etc, may not contain /. + # Note that this test may fail for the wrong reason if /dir + # itself does not already exist, so first let's make /dir. + only_dotl = getbool(tstate.config, 'client', 'only_dotl', False) + with TestCase('mkdir', tstate) as tc: + clnt = tc.ccs() + if only_dotl and not clnt.supports(protocol.td.Tmkdir): + tc.skip('cannot test dot-L mkdir on {0}'.format(clnt.proto)) + try: + fid, qid = clnt.uxlookup(b'/dir', None) + tc.autoclunk(fid) + tstate.stop = True + tc.fail('found existing /dir after cleaning tree') + except RemoteError as err: + # we'll just assume it's "no such file or directory" + pass + if only_dotl: + qid = clnt.mkdir(clnt.rootfid, b'dir', 0o777, tstate.gid) + else: + qid, _ = clnt.create(clnt.rootfid, b'dir', + protocol.td.DMDIR | 0o777, + protocol.td.OREAD) + if qid.type != protocol.td.QTDIR: + tstate.stop = True + tc.fail('creating /dir: result is not a directory') + tc.trace('now attempting to create /dir/sub the wrong way') + try: + if only_dotl: + qid = clnt.mkdir(clnt.rootfid, b'dir/sub', 0o777, tstate.gid) + else: + qid, _ = clnt.create(clnt.rootfid, b'dir/sub', + protocol.td.DMDIR | 0o777, + protocol.td.OREAD) + # it's not clear what happened on the server at this point! + tc.trace("creating dir/sub (with embedded '/') should have " + 'failed but did not') + tstate.stop = True + fset = clnt.uxreaddir(b'/dir') + if 'sub' in fset: + tc.trace('(found our dir/sub detritus)') + clnt.uxremove(b'dir/sub', force=True) + fset = clnt.uxreaddir(b'/dir') + if 'sub' not in fset: + tc.trace('(successfully removed our dir/sub detritus)') + tstate.stop = False + tc.fail('created dir/sub as single directory with embedded slash') + except RemoteError as err: + # we'll just assume it's the right kind of error + tc.trace('invalid path dir/sub failed with: %s', str(err)) + tc.succ('embedded slash in mkdir correctly refused') + if tstate.stop: + return + + with TestCase('getattr/setattr', tstate) as tc: + # This test is not really thorough enough, need to test + # all combinations of settings. Should also test that + # old values are restored on failure, although it is not + # clear how to trigger failures. + clnt = tc.ccs() + if not clnt.supports(protocol.td.Tgetattr): + tc.skip('%s does not support Tgetattr', clnt) + fid, _, _, _ = clnt.uxopen(b'/dir/file', os.O_CREAT | os.O_RDWR, 0o666, + gid=tstate.gid) + tc.autoclunk(fid) + written = clnt.write(fid, 0, 'bytes\n') + if written != 6: + tc.trace('expected to write 6 bytes, actually wrote %d', written, + level=logging.WARN) + attrs = clnt.Tgetattr(fid) + #tc.trace('getattr: after write, before setattr: got %s', attrs) + if attrs.size != written: + tc.fail('getattr: expected size=%d, got size=%d', + written, attrs.size) + # now truncate, set mtime to (3,14), and check result + set_time_to = p9conn.Timespec(sec=0, nsec=140000000) + clnt.Tsetattr(fid, size=0, mtime=set_time_to) + attrs = clnt.Tgetattr(fid) + #tc.trace('getattr: after setattr: got %s', attrs) + if attrs.mtime.sec != set_time_to.sec or attrs.size != 0: + tc.fail('setattr: expected to get back mtime.sec={0}, size=0; ' + 'got mtime.sec={1}, size=' + '{1}'.format(set_time_to.sec, attrs.mtime.sec, attrs.size)) + # nsec is not as stable but let's check + if attrs.mtime.nsec != set_time_to.nsec: + tc.trace('setattr: expected to get back mtime_nsec=%d; ' + 'got %d', set_time_to.nsec, mtime_nsec) + tc.succ('able to set and see size and mtime') + + # this test should be much later, but we know the current + # server is broken... + with TestCase('rename adjusts other fids', tstate) as tc: + clnt = tc.ccs() + dirfid, _ = clnt.uxlookup(b'/dir') + tc.autoclunk(dirfid) + clnt.uxmkdir(b'd1', 0o777, tstate.gid, startdir=dirfid) + clnt.uxmkdir(b'd1/sub', 0o777, tstate.gid, startdir=dirfid) + d1fid, _ = clnt.uxlookup(b'd1', dirfid) + tc.autoclunk(d1fid) + subfid, _ = clnt.uxlookup(b'sub', d1fid) + tc.autoclunk(subfid) + fid, _, _, _ = clnt.uxopen(b'file', os.O_CREAT | os.O_RDWR, + 0o666, startdir=subfid, gid=tstate.gid) + tc.autoclunk(fid) + written = clnt.write(fid, 0, 'filedata\n') + if written != 9: + tc.trace('expected to write 9 bytes, actually wrote %d', written, + level=logging.WARN) + # Now if we rename /dir/d1 to /dir/d2, the fids for both + # sub/file and sub itself should still be usable. This + # holds for both Trename (Linux only) and Twstat based + # rename ops. + # + # Note that some servers may cache some number of files and/or + # diretories held open, so we should open many fids to wipe + # out the cache (XXX notyet). + if clnt.supports(protocol.td.Trename): + clnt.rename(d1fid, dirfid, name=b'd2') + else: + clnt.wstat(d1fid, name=b'd2') + try: + rofid, _, _, _ = clnt.uxopen(b'file', os.O_RDONLY, startdir=subfid) + clnt.clunk(rofid) + except RemoteError as err: + tc.fail('open file in renamed dir/d2/sub: {0}'.format(err)) + tc.succ() + + # Even if xattrwalk is supported by the protocol, it's optional + # on the server. + with TestCase('xattrwalk', tstate) as tc: + clnt = tc.ccs() + if not clnt.supports(protocol.td.Txattrwalk): + tc.skip('{0} does not support Txattrwalk'.format(clnt)) + dirfid, _ = clnt.uxlookup(b'/dir') + tc.autoclunk(dirfid) + try: + # need better tests... + attrfid, size = clnt.xattrwalk(dirfid) + tc.autoclunk(attrfid) + data = clnt.read(attrfid, 0, size) + tc.trace('xattrwalk with no name: data=%r', data) + tc.succ('xattrwalk size={0} datalen={1}'.format(size, len(data))) + except RemoteError as err: + tc.trace('xattrwalk on /dir: {0}'.format(err)) + tc.succ('xattrwalk apparently not implemented') + +if __name__ == '__main__': + try: + sys.exit(main()) + except KeyboardInterrupt: + sys.exit('\nInterrupted') |
