xref: /freebsd/contrib/lib9p/pytest/client.py (revision 8ddb146abcdf061be9f2c0db7e391697dafad85c)
1#! /usr/bin/env python
2
3"""
4Run various tests, as a client.
5"""
6
7from __future__ import print_function
8
9import argparse
10try:
11    import ConfigParser as configparser
12except ImportError:
13    import configparser
14import functools
15import logging
16import os
17import socket
18import struct
19import sys
20import time
21import traceback
22
23import p9conn
24import protocol
25
26LocalError = p9conn.LocalError
27RemoteError = p9conn.RemoteError
28TEError = p9conn.TEError
29
30class TestState(object):
31    def __init__(self):
32        self.config = None
33        self.logger = None
34        self.successes = 0
35        self.skips = 0
36        self.failures = 0
37        self.exceptions = 0
38        self.clnt_tab = {}
39        self.mkclient = None
40        self.stop = False
41        self.gid = 0
42
43    def ccc(self, cid=None):
44        """
45        Connect or reconnect as client (ccc = check and connect client).
46
47        If caller provides a cid (client ID) we check that specific
48        client.  Otherwise the default ID ('base') is used.
49        In any case we return the now-connected client, plus the
50        attachment (session info) if any.
51        """
52        if cid is None:
53            cid = 'base'
54        pair = self.clnt_tab.get(cid)
55        if pair is None:
56            clnt = self.mkclient()
57            pair = [clnt, None]
58            self.clnt_tab[cid] = pair
59        else:
60            clnt = pair[0]
61        if not clnt.is_connected():
62            clnt.connect()
63        return pair
64
65    def dcc(self, cid=None):
66        """
67        Disconnect client (disconnect checked client).  If no specific
68        client ID is provided, this disconnects ALL checked clients!
69        """
70        if cid is None:
71            for cid in list(self.clnt_tab.keys()):
72                self.dcc(cid)
73        pair = self.clnt_tab.get(cid)
74        if pair is not None:
75            clnt = pair[0]
76            if clnt.is_connected():
77                clnt.shutdown()
78            del self.clnt_tab[cid]
79
80    def ccs(self, cid=None):
81        """
82        Like ccc, but establish a session as well, by setting up
83        the uname/n_uname.
84
85        Return the client instance (only).
86        """
87        pair = self.ccc(cid)
88        clnt = pair[0]
89        if pair[1] is None:
90            # No session yet - establish one.  Note, this may fail.
91            section = None if cid is None else ('client-' + cid)
92            aname = getconf(self.config, section, 'aname', '')
93            uname = getconf(self.config, section, 'uname', '')
94            if clnt.proto > protocol.plain:
95                n_uname = getint(self.config, section, 'n_uname', 1001)
96            else:
97                n_uname = None
98            clnt.attach(afid=None, aname=aname, uname=uname, n_uname=n_uname)
99            pair[1] = (aname, uname, n_uname)
100        return clnt
101
102def getconf(conf, section, name, default=None, rtype=str):
103    """
104    Get configuration item for given section, or for "client" if
105    there is no entry for that particular section (or if section
106    is None).
107
108    This lets us get specific values for specific tests or
109    groups ([foo] name=value), falling back to general values
110    ([client] name=value).
111
112    The type of the returned value <rtype> can be str, int, bool,
113    or float.  The default is str (and see getconfint, getconfbool,
114    getconffloat below).
115
116    A default value may be supplied; if it is, that's the default
117    return value (this default should have the right type).  If
118    no default is supplied, a missing value is an error.
119    """
120    try:
121        # note: conf.get(None, 'foo') raises NoSectionError
122        where = section
123        result = conf.get(where, name)
124    except (configparser.NoSectionError, configparser.NoOptionError):
125        try:
126            where = 'client'
127            result = conf.get(where, name)
128        except configparser.NoSectionError:
129            sys.exit('no [{0}] section in configuration!'.format(where))
130        except configparser.NoOptionError:
131            if default is not None:
132                return default
133            if section is not None:
134                where = '[{0}] or [{1}]'.format(section, where)
135            else:
136                where = '[{0}]'.format(where)
137            raise LocalError('need {0}=value in {1}'.format(name, where))
138    where = '[{0}]'.format(where)
139    if rtype is str:
140        return result
141    if rtype is int:
142        return int(result)
143    if rtype is float:
144        return float(result)
145    if rtype is bool:
146        if result.lower() in ('1', 't', 'true', 'y', 'yes'):
147            return True
148        if result.lower() in ('0', 'f', 'false', 'n', 'no'):
149            return False
150        raise ValueError('{0} {1}={2}: invalid boolean'.format(where, name,
151                                                              result))
152    raise ValueError('{0} {1}={2}: internal error: bad result type '
153                     '{3!r}'.format(where, name, result, rtype))
154
155def getint(conf, section, name, default=None):
156    "get integer config item"
157    return getconf(conf, section, name, default, int)
158
159def getfloat(conf, section, name, default=None):
160    "get float config item"
161    return getconf(conf, section, name, default, float)
162
163def getbool(conf, section, name, default=None):
164    "get boolean config item"
165    return getconf(conf, section, name, default, bool)
166
167def pluralize(n, singular, plural):
168    "return singular or plural based on value of n"
169    return plural if n != 1 else singular
170
171class TCDone(Exception):
172    "used in succ/fail/skip - skips rest of testcase with"
173    pass
174
175class TestCase(object):
176    """
177    Start a test case.  Most callers must then do a ccs() to connect.
178
179    A failed test will generally disconnect from the server; a
180    new ccs() will reconnect, if the server is still alive.
181    """
182    def __init__(self, name, tstate):
183        self.name = name
184        self.status = None
185        self.detail = None
186        self.tstate = tstate
187        self._shutdown = None
188        self._autoclunk = None
189        self._acconn = None
190
191    def auto_disconnect(self, conn):
192        self._shutdown = conn
193
194    def succ(self, detail=None):
195        "set success status"
196        self.status = 'SUCC'
197        self.detail = detail
198        raise TCDone()
199
200    def fail(self, detail):
201        "set failure status"
202        self.status = 'FAIL'
203        self.detail = detail
204        raise TCDone()
205
206    def skip(self, detail=None):
207        "set skip status"
208        self.status = 'SKIP'
209        self.detail = detail
210        raise TCDone()
211
212    def autoclunk(self, fid):
213        "mark fid to be closed/clunked on test exit"
214        if self._acconn is None:
215            raise ValueError('autoclunk: no _acconn')
216        self._autoclunk.append(fid)
217
218    def trace(self, msg, *args, **kwargs):
219        "add tracing info to log-file output"
220        level = kwargs.pop('level', logging.INFO)
221        self.tstate.logger.log(level, '      ' + msg, *args, **kwargs)
222
223    def ccs(self):
224        "call tstate ccs, turn socket.error connect failure into test fail"
225        try:
226            self.detail = 'connecting'
227            ret = self.tstate.ccs()
228            self.detail = None
229            self._acconn = ret
230            return ret
231        except socket.error as err:
232            self.fail(str(err))
233
234    def __enter__(self):
235        self.tstate.logger.log(logging.DEBUG, 'ENTER: %s', self.name)
236        self._autoclunk = []
237        return self
238
239    def __exit__(self, exc_type, exc_val, exc_tb):
240        tstate = self.tstate
241        eat_exc = False
242        tb_detail = None
243        if exc_type is TCDone:
244            # we exited with succ, fail, or skip
245            eat_exc = True
246            exc_type = None
247        if exc_type is not None:
248            if self.status is None:
249                self.status = 'EXCP'
250            else:
251                self.status += ' EXC'
252            if exc_type == TEError:
253                # timeout/eof - best guess is that we crashed the server!
254                eat_exc = True
255                tb_detail = ['timeout or EOF']
256            elif exc_type in (socket.error, RemoteError, LocalError):
257                eat_exc = True
258                tb_detail = traceback.format_exception(exc_type, exc_val,
259                                                       exc_tb)
260            level = logging.ERROR
261            tstate.failures += 1
262            tstate.exceptions += 1
263        else:
264            if self.status is None:
265                self.status = 'SUCC'
266            if self.status == 'SUCC':
267                level = logging.INFO
268                tstate.successes += 1
269            elif self.status == 'SKIP':
270                level = logging.INFO
271                tstate.skips += 1
272            else:
273                level = logging.ERROR
274                tstate.failures += 1
275        tstate.logger.log(level, '%s: %s', self.status, self.name)
276        if self.detail:
277            tstate.logger.log(level, '      detail: %s', self.detail)
278        if tb_detail:
279            for line in tb_detail:
280                tstate.logger.log(level, '      %s', line.rstrip())
281        for fid in self._autoclunk:
282            self._acconn.clunk(fid, ignore_error=True)
283        if self._shutdown:
284            self._shutdown.shutdown()
285        return eat_exc
286
287def main():
288    "the usual main"
289    parser = argparse.ArgumentParser(description='run tests against a server')
290
291    parser.add_argument('-c', '--config',
292        action='append',
293        help='specify additional file(s) to read (beyond testconf.ini)')
294
295    args = parser.parse_args()
296    config = configparser.SafeConfigParser()
297    # use case sensitive keys
298    config.optionxform = str
299
300    try:
301        with open('testconf.ini', 'r') as stream:
302            config.readfp(stream)
303    except (OSError, IOError) as err:
304        sys.exit(str(err))
305    if args.config:
306        ok = config.read(args.config)
307        failed = set(ok) - set(args.config)
308        if len(failed):
309            nfailed = len(failed)
310            word = 'files' if nfailed > 1 else 'file'
311            failed = ', '.join(failed)
312            print('failed to read {0} {1}: {2}'.format(nfailed, word, failed))
313            sys.exit(1)
314
315    logging.basicConfig(level=config.get('client', 'loglevel').upper())
316    logger = logging.getLogger(__name__)
317    tstate = TestState()
318    tstate.logger = logger
319    tstate.config = config
320
321    server = config.get('client', 'server')
322    port = config.getint('client', 'port')
323    proto = config.get('client', 'protocol')
324    may_downgrade = config.getboolean('client', 'may_downgrade')
325    timeout = config.getfloat('client', 'timeout')
326
327    tstate.stop = True # unless overwritten below
328    with TestCase('send bad packet', tstate) as tc:
329        tc.detail = 'connecting to {0}:{1}'.format(server, port)
330        try:
331            conn = p9conn.P9SockIO(logger, server=server, port=port)
332        except socket.error as err:
333            tc.fail('cannot connect at all (server down?)')
334        tc.auto_disconnect(conn)
335        tc.detail = None
336        pkt = struct.pack('<I', 256);
337        conn.write(pkt)
338        # ignore reply if any, we're just trying to trip the server
339        tstate.stop = False
340        tc.succ()
341
342    if not tstate.stop:
343        tstate.mkclient = functools.partial(p9conn.P9Client, logger,
344                                           timeout, proto, may_downgrade,
345                                           server=server, port=port)
346        tstate.stop = True
347        with TestCase('send bad Tversion', tstate) as tc:
348            try:
349                clnt = tstate.mkclient()
350            except socket.error as err:
351                tc.fail('can no longer connect, did bad pkt crash server?')
352            tc.auto_disconnect(clnt)
353            clnt.set_monkey('version', b'wrongo, fishbreath!')
354            tc.detail = 'connecting'
355            try:
356                clnt.connect()
357            except RemoteError as err:
358                tstate.stop = False
359                tc.succ(err.args[0])
360            tc.fail('server accepted a bad Tversion')
361
362    if not tstate.stop:
363        # All NUL characters in strings are invalid.
364        with TestCase('send illegal NUL in Tversion', tstate) as tc:
365            clnt = tstate.mkclient()
366            tc.auto_disconnect(clnt)
367            clnt.set_monkey('version', b'9P2000\0')
368            # Forcibly allow downgrade so that Tversion
369            # succeeds if they ignore the \0.
370            clnt.may_downgrade = True
371            tc.detail = 'connecting'
372            try:
373                clnt.connect()
374            except (TEError, RemoteError) as err:
375                tc.succ(err.args[0])
376            tc.fail('server accepted NUL in Tversion')
377
378    if not tstate.stop:
379        with TestCase('connect normally', tstate) as tc:
380            tc.detail = 'connecting'
381            try:
382                tstate.ccc()
383            except RemoteError as err:
384                # can't test any further, but this might be success
385                tstate.stop = True
386                if 'they only support version' in err.args[0]:
387                    tc.succ(err.args[0])
388                tc.fail(err.args[0])
389            tc.succ()
390
391    if not tstate.stop:
392        with TestCase('attach with bad afid', tstate) as tc:
393            clnt = tstate.ccc()[0]
394            section = 'attach-with-bad-afid'
395            aname = getconf(tstate.config, section, 'aname', '')
396            uname = getconf(tstate.config, section, 'uname', '')
397            if clnt.proto > protocol.plain:
398                n_uname = getint(tstate.config, section, 'n_uname', 1001)
399            else:
400                n_uname = None
401            try:
402                clnt.attach(afid=42, aname=aname, uname=uname, n_uname=n_uname)
403            except RemoteError as err:
404                tc.succ(err.args[0])
405            tc.dcc()
406            tc.fail('bad attach afid not rejected')
407
408    try:
409        if not tstate.stop:
410            # Various Linux tests need gids.  Just get them for everyone.
411            tstate.gid = getint(tstate.config, 'client', 'gid', 0)
412            more_test_cases(tstate)
413    finally:
414        tstate.dcc()
415
416    n_tests = tstate.successes + tstate.failures
417    print('summary:')
418    if tstate.successes:
419        print('{0}/{1} tests succeeded'.format(tstate.successes, n_tests))
420    if tstate.failures:
421        print('{0}/{1} tests failed'.format(tstate.failures, n_tests))
422    if tstate.skips:
423        print('{0} {1} skipped'.format(tstate.skips,
424                                       pluralize(tstate.skips,
425                                                 'test', 'tests')))
426    if tstate.exceptions:
427        print('{0} {1} occurred'.format(tstate.exceptions,
428                                       pluralize(tstate.exceptions,
429                                                 'exception', 'exceptions')))
430    if tstate.stop:
431        print('tests stopped early')
432    return 1 if tstate.stop or tstate.exceptions or tstate.failures else 0
433
434def more_test_cases(tstate):
435    "run cases that can only proceed if connecting works at all"
436    with TestCase('attach normally', tstate) as tc:
437        tc.ccs()
438        tc.succ()
439    if tstate.stop:
440        return
441
442    # Empty string is not technically illegal.  It's not clear
443    # whether it should be accepted or rejected.  However, it
444    # used to crash the server entirely, so it's a desirable
445    # test case.
446    with TestCase('empty string in Twalk request', tstate) as tc:
447        clnt = tc.ccs()
448        try:
449            fid, qid = clnt.lookup(clnt.rootfid, [b''])
450        except RemoteError as err:
451            tc.succ(err.args[0])
452        clnt.clunk(fid)
453        tc.succ('note: empty Twalk component name not rejected')
454
455    # Name components may not contain /
456    with TestCase('embedded / in lookup component name', tstate) as tc:
457        clnt = tc.ccs()
458        try:
459            fid, qid = clnt.lookup(clnt.rootfid, [b'/'])
460            tc.autoclunk(fid)
461        except RemoteError as err:
462            tc.succ(err.args[0])
463        tc.fail('/ in lookup component name not rejected')
464
465    # Proceed from a clean tree.  As a side effect, this also tests
466    # either the old style readdir (read() on a directory fid) or
467    # the dot-L readdir().
468    #
469    # The test case will fail if we don't have permission to remove
470    # some file(s).
471    with TestCase('clean up tree (readdir+remove)', tstate) as tc:
472        clnt = tc.ccs()
473        fset = clnt.uxreaddir(b'/')
474        fset = [i for i in fset if i != '.' and i != '..']
475        tc.trace("what's there initially: {0!r}".format(fset))
476        try:
477            clnt.uxremove(b'/', force=False, recurse=True)
478        except RemoteError as err:
479            tc.trace('failed to read or clean up tree', level=logging.ERROR)
480            tc.trace('this might be a permissions error', level=logging.ERROR)
481            tstate.stop = True
482            tc.fail(str(err))
483        fset = clnt.uxreaddir(b'/')
484        fset = [i for i in fset if i != '.' and i != '..']
485        tc.trace("what's left after removing everything: {0!r}".format(fset))
486        if fset:
487            tstate.stop = True
488            tc.trace('note: could be a permissions error', level=logging.ERROR)
489            tc.fail('/ not empty after removing all: {0!r}'.format(fset))
490        tc.succ()
491    if tstate.stop:
492        return
493
494    # Name supplied to create, mkdir, etc, may not contain /.
495    # Note that this test may fail for the wrong reason if /dir
496    # itself does not already exist, so first let's make /dir.
497    only_dotl = getbool(tstate.config, 'client', 'only_dotl', False)
498    with TestCase('mkdir', tstate) as tc:
499        clnt = tc.ccs()
500        if only_dotl and not clnt.supports(protocol.td.Tmkdir):
501            tc.skip('cannot test dot-L mkdir on {0}'.format(clnt.proto))
502        try:
503            fid, qid = clnt.uxlookup(b'/dir', None)
504            tc.autoclunk(fid)
505            tstate.stop = True
506            tc.fail('found existing /dir after cleaning tree')
507        except RemoteError as err:
508            # we'll just assume it's "no such file or directory"
509            pass
510        if only_dotl:
511            qid = clnt.mkdir(clnt.rootfid, b'dir', 0o777, tstate.gid)
512        else:
513            qid, _ = clnt.create(clnt.rootfid, b'dir',
514                                 protocol.td.DMDIR | 0o777,
515                                 protocol.td.OREAD)
516        if qid.type != protocol.td.QTDIR:
517            tstate.stop = True
518            tc.fail('creating /dir: result is not a directory')
519        tc.trace('now attempting to create /dir/sub the wrong way')
520        try:
521            if only_dotl:
522                qid = clnt.mkdir(clnt.rootfid, b'dir/sub', 0o777, tstate.gid)
523            else:
524                qid, _ = clnt.create(clnt.rootfid, b'dir/sub',
525                                     protocol.td.DMDIR | 0o777,
526                                     protocol.td.OREAD)
527            # it's not clear what happened on the server at this point!
528            tc.trace("creating dir/sub (with embedded '/') should have "
529                     'failed but did not')
530            tstate.stop = True
531            fset = clnt.uxreaddir(b'/dir')
532            if 'sub' in fset:
533                tc.trace('(found our dir/sub detritus)')
534                clnt.uxremove(b'dir/sub', force=True)
535                fset = clnt.uxreaddir(b'/dir')
536                if 'sub' not in fset:
537                    tc.trace('(successfully removed our dir/sub detritus)')
538                    tstate.stop = False
539            tc.fail('created dir/sub as single directory with embedded slash')
540        except RemoteError as err:
541            # we'll just assume it's the right kind of error
542            tc.trace('invalid path dir/sub failed with: %s', str(err))
543            tc.succ('embedded slash in mkdir correctly refused')
544    if tstate.stop:
545        return
546
547    with TestCase('getattr/setattr', tstate) as tc:
548        # This test is not really thorough enough, need to test
549        # all combinations of settings.  Should also test that
550        # old values are restored on failure, although it is not
551        # clear how to trigger failures.
552        clnt = tc.ccs()
553        if not clnt.supports(protocol.td.Tgetattr):
554            tc.skip('%s does not support Tgetattr', clnt)
555        fid, _, _, _ = clnt.uxopen(b'/dir/file', os.O_CREAT | os.O_RDWR, 0o666,
556            gid=tstate.gid)
557        tc.autoclunk(fid)
558        written = clnt.write(fid, 0, 'bytes\n')
559        if written != 6:
560            tc.trace('expected to write 6 bytes, actually wrote %d', written,
561                     level=logging.WARN)
562        attrs = clnt.Tgetattr(fid)
563        #tc.trace('getattr: after write, before setattr: got %s', attrs)
564        if attrs.size != written:
565            tc.fail('getattr: expected size=%d, got size=%d',
566                    written, attrs.size)
567        # now truncate, set mtime to (3,14), and check result
568        set_time_to = p9conn.Timespec(sec=0, nsec=140000000)
569        clnt.Tsetattr(fid, size=0, mtime=set_time_to)
570        attrs = clnt.Tgetattr(fid)
571        #tc.trace('getattr: after setattr: got %s', attrs)
572        if attrs.mtime.sec != set_time_to.sec or attrs.size != 0:
573            tc.fail('setattr: expected to get back mtime.sec={0}, size=0; '
574                    'got mtime.sec={1}, size='
575                    '{1}'.format(set_time_to.sec, attrs.mtime.sec, attrs.size))
576        # nsec is not as stable but let's check
577        if attrs.mtime.nsec != set_time_to.nsec:
578            tc.trace('setattr: expected to get back mtime_nsec=%d; '
579                     'got %d', set_time_to.nsec, mtime_nsec)
580        tc.succ('able to set and see size and mtime')
581
582    # this test should be much later, but we know the current
583    # server is broken...
584    with TestCase('rename adjusts other fids', tstate) as tc:
585        clnt = tc.ccs()
586        dirfid, _ = clnt.uxlookup(b'/dir')
587        tc.autoclunk(dirfid)
588        clnt.uxmkdir(b'd1', 0o777, tstate.gid, startdir=dirfid)
589        clnt.uxmkdir(b'd1/sub', 0o777, tstate.gid, startdir=dirfid)
590        d1fid, _ = clnt.uxlookup(b'd1', dirfid)
591        tc.autoclunk(d1fid)
592        subfid, _ = clnt.uxlookup(b'sub', d1fid)
593        tc.autoclunk(subfid)
594        fid, _, _, _ = clnt.uxopen(b'file', os.O_CREAT | os.O_RDWR,
595                                   0o666, startdir=subfid, gid=tstate.gid)
596        tc.autoclunk(fid)
597        written = clnt.write(fid, 0, 'filedata\n')
598        if written != 9:
599            tc.trace('expected to write 9 bytes, actually wrote %d', written,
600                     level=logging.WARN)
601        # Now if we rename /dir/d1 to /dir/d2, the fids for both
602        # sub/file and sub itself should still be usable.  This
603        # holds for both Trename (Linux only) and Twstat based
604        # rename ops.
605        #
606        # Note that some servers may cache some number of files and/or
607        # diretories held open, so we should open many fids to wipe
608        # out the cache (XXX notyet).
609        if clnt.supports(protocol.td.Trename):
610            clnt.rename(d1fid, dirfid, name=b'd2')
611        else:
612            clnt.wstat(d1fid, name=b'd2')
613        try:
614            rofid, _, _, _ = clnt.uxopen(b'file', os.O_RDONLY, startdir=subfid)
615            clnt.clunk(rofid)
616        except RemoteError as err:
617            tc.fail('open file in renamed dir/d2/sub: {0}'.format(err))
618        tc.succ()
619
620    # Even if xattrwalk is supported by the protocol, it's optional
621    # on the server.
622    with TestCase('xattrwalk', tstate) as tc:
623        clnt = tc.ccs()
624        if not clnt.supports(protocol.td.Txattrwalk):
625            tc.skip('{0} does not support Txattrwalk'.format(clnt))
626        dirfid, _ = clnt.uxlookup(b'/dir')
627        tc.autoclunk(dirfid)
628        try:
629            # need better tests...
630            attrfid, size = clnt.xattrwalk(dirfid)
631            tc.autoclunk(attrfid)
632            data = clnt.read(attrfid, 0, size)
633            tc.trace('xattrwalk with no name: data=%r', data)
634            tc.succ('xattrwalk size={0} datalen={1}'.format(size, len(data)))
635        except RemoteError as err:
636            tc.trace('xattrwalk on /dir: {0}'.format(err))
637        tc.succ('xattrwalk apparently not implemented')
638
639if __name__ == '__main__':
640    try:
641        sys.exit(main())
642    except KeyboardInterrupt:
643        sys.exit('\nInterrupted')
644