1#!@PYTHON@
2#
3# This file and its contents are supplied under the terms of the
4# Common Development and Distribution License ("CDDL"), version 1.0.
5# You may only use this file in accordance with the terms of version
6# 1.0 of the CDDL.
7#
8# A full copy of the text of the CDDL should have accompanied this
9# source.  A copy of the CDDL is also available via the Internet at
10# http://www.illumos.org/license/CDDL.
11#
12
13#
14# Copyright 2022 Tintri by DDN, Inc. All rights reserved.
15#
16
17#
18# Run tests provided by smbtorture.
19#
20
21import subprocess
22import argparse
23import re
24import fnmatch
25
26from enum import Enum
27from datetime import datetime
28from tempfile import TemporaryFile
29
30def stripped_file(f):
31    """Strips trailing whitespace from lines in f"""
32
33    for line in f:
34        yield line.strip()
35
36def parse_tests(f):
37    """Returns test names from f, skipping commented lines"""
38
39    yield from (line for line in f
40        if line and not line.startswith('#'))
41
42def matched_suites(m):
43    """Gets all smbtorture tests that match pattern m"""
44
45    with TemporaryFile('w+') as tmp:
46        subprocess.run(['smbtorture', '--list'], stdout=tmp,
47            universal_newlines=True)
48        tmp.seek(0)
49        yield from (line for line in stripped_file(tmp)
50                    if not line.startswith('smbtorture') and
51                    m.match(line))
52
53class TestResult(Enum):
54    PASS = 0
55    FAIL = 1
56    UNKNOWN = 2
57    SKIP = 3
58    KILLED = 4
59    TEST_ERR = 5
60
61    def __str__(self):
62        return self.name
63
64    def __len__(self):
65        return len(self.name)
66
67class TestCase:
68    """A particular instance of an smbtorture test"""
69
70    __slots__ = 'name', 'result'
71
72    def __init__(self, name):
73        self.name = name
74        self.result = TestResult.UNKNOWN
75
76    def __str__(self):
77        return f'{self.name} | {self.result}'
78
79    def run(self, rfd, wfd, timeout, cmd):
80        """Run cmd, setting the last element to the test name, and setting result
81        based on rfd. Output is sent to wfd, and the test is killed based on timeout."""
82
83        def finish(self, start, wfd):
84            timediff = datetime.now() - start
85            wfd.write(f'END   | {self} | {timediff}\n')
86            return self.result
87
88        starttime = datetime.now()
89        wfd.write(f'START | {self.name} | {starttime.time()}\n')
90        if self.result == TestResult.SKIP:
91            return finish(self, starttime, wfd)
92
93        cmd[-1] = self.name
94        try:
95            subprocess.run(cmd, universal_newlines=True, stdout=wfd,
96                stderr=subprocess.STDOUT, timeout=timeout)
97            for line in stripped_file(rfd):
98                if self.result != TestResult.UNKNOWN:
99                    continue
100                elif line.startswith('failure:') or line.startswith('error:'):
101                    self.result = TestResult.FAIL
102                elif line.startswith('success:'):
103                    self.result = TestResult.PASS
104                elif line.startswith('skip:'):
105                    self.result = TestResult.SKIP
106                elif line.startswith('INTERNAL ERROR:'):
107                    self.result = TestResult.TEST_ERR
108        except subprocess.TimeoutExpired:
109            self.result = TestResult.KILLED
110            wfd.write('\nKilled due to timeout\n')
111            rfd.read()
112
113        return finish(self, starttime, wfd)
114
115class TestSet:
116    """Class to track state associated with the entire test set"""
117
118    __slots__ = 'excluded', 'tests'
119
120    def __init__(self, tests, skip_pat, verbose):
121        self.excluded = 0
122
123        def should_skip(self, test, pattern, verbose):
124            """Returns whether test matches pattern, indicating it should be
125            skipped."""
126
127            if not pattern or not pattern.match(test):
128                return False
129
130            if verbose:
131                print(f'{test} matches exception pattern; marking as skipped')
132
133            self.excluded += 1
134            return True
135
136        self.tests = [TestCase(line) for line in tests
137            if not should_skip(self, line, skip_pat, verbose)]
138
139
140    def __iter__(self):
141        return iter(self.tests)
142
143    def __len__(self):
144        return len(self.tests)
145
146def fnm2regex(fnm_pat):
147    """Maps an fnmatch(7) pattern to a regex pattern that will match against
148    any suite that encapsulates the test name"""
149
150    rpat = fnmatch.translate(fnm_pat)
151
152    #
153    # If the pattern doesn't end with '*', we also need it to match against
154    # any sub-module; '*test' needs to also match 'smb2.test.first', but
155    # not 'smb2.test-other.second'.
156    #
157    if not fnm_pat.endswith('*'):
158        rpat += '|' + fnmatch.translate(fnm_pat + '.*')
159    return rpat
160
161def verbose_fnm2regex(fnm_pat):
162    """fnm2regex(), but prints the input and output patterns"""
163    ret_pat = fnm2regex(fnm_pat)
164    print(f'fnmatch: {fnm_pat} regex: {ret_pat}')
165    return ret_pat
166
167def combine_patterns(iterable, verbose):
168    """Combines patterns in an iterable into a single REGEX"""
169
170    if verbose > 1:
171        func = verbose_fnm2regex
172    else:
173        func = fnm2regex
174
175    fnmatch_pat = '|'.join(map(func, iterable))
176
177    if not fnmatch_pat:
178        pat = None;
179    else:
180        pat = re.compile(fnmatch_pat, flags=re.DEBUG if verbose > 2 else 0)
181
182    if verbose > 1:
183        print(f'final pattern: {pat.pattern if pat else "<None>"}')
184    return pat
185
186class ArgumentFile(argparse.FileType):
187    """argparse.FileType, but wrapped in stripped_file()"""
188
189    def __call__(self, *args, **kwargs):
190        return stripped_file(argparse.FileType.__call__(self, *args, **kwargs))
191
192
193def main():
194    parser = argparse.ArgumentParser(description=
195        'Run a set of smbtorture tests, parsing the results.')
196
197    parser.add_argument('server', help='The target server')
198    parser.add_argument('share', help='The target share')
199    parser.add_argument('user', help='Username for smbtorture')
200    parser.add_argument('password', help='Password for user')
201
202    parser.add_argument('--except', '-e',
203        type=ArgumentFile('r'), metavar='EXCEPTIONS_FILE', dest='skip_list',
204        help='A file containing fnmatch(7) patterns of tests to skip')
205    parser.add_argument('--list', '-l',
206        type=ArgumentFile('r'), metavar='LIST_FILE',
207        help='A file containing the list of tests to run')
208    parser.add_argument('--match', '-m',
209        action='append', metavar='FNMATCH',
210        help='An fnmatch(7) pattern to select tests from smbtorture --list')
211    parser.add_argument('--output', '-o',
212        default='/tmp/lastrun.log', metavar='LOG_FILE',
213        help='Location to store full smbtorture output')
214    parser.add_argument('--seed', '-s',
215        type=int,
216        help='Seed passed to smbtorture')
217    parser.add_argument('--timeout', '-t',
218        default=120, type=float,
219        help='Timeout after which test is killed')
220    parser.add_argument('--verbose', '-v',
221        action='count', default=0,
222        help='Verbose output')
223
224    args = parser.parse_args()
225
226    if (args.match == None) == (args.list == None):
227        print('Must provide one of -l and -m')
228        return
229
230    server = args.server
231    share = args.share
232    user = args.user
233    pswd = args.password
234    fout = args.output
235
236    if args.match != None:
237        if args.verbose > 1:
238            print('Patterns to match:')
239            print(*args.match)
240
241        testgen = matched_suites(combine_patterns(args.match, args.verbose))
242    else:
243        testgen = args.list
244
245    if args.skip_list != None:
246        skip_pat = combine_patterns(parse_tests(args.skip_list), args.verbose)
247        if args.verbose > 1:
248            exc_pat = skip_pat.pattern if skip_pat else '<NONE>'
249            print(f'Exceptions pattern (in REGEX): {exc_pat}')
250    else:
251        skip_pat = None
252
253    tests = TestSet(parse_tests(testgen), skip_pat, args.verbose)
254
255    if args.verbose:
256        print('Tests to run:')
257        for test in tests:
258            print(test.name)
259
260    outw = open(fout, 'w', buffering=1)
261    outr = open(fout, 'r')
262
263    cmd = f'smbtorture //{server}/{share} -U{user}%{pswd}'.split()
264    if args.seed != None:
265        cmd.append(f'--seed={args.seed}')
266    cmd.append('TEST_HERE')
267
268    if args.verbose:
269        print('Command to run:')
270        print(*cmd)
271
272    results = {res: 0 for res in TestResult}
273    for test in tests:
274        print(test.name, end=': ', flush=True)
275        res = test.run(outr, outw, args.timeout, cmd)
276        results[res] += 1
277        print(res, flush=True)
278
279    print('\n\nRESULTS:')
280    print('=' * 22)
281    for res in TestResult:
282        print(f'{res}: {results[res]:>{20 - len(res)}}')
283    print('=' * 22)
284    print(f'Total: {len(tests):>15}')
285    print(f'Excluded: {tests.excluded:>12}')
286
287if __name__ == '__main__':
288    try:
289        main()
290    except KeyboardInterrupt:
291        print('Terminated by KeyboardInterrupt.')
292