]> git.sesse.net Git - bcachefs-tools-debian/blob - tests/util.py
Merge commit '780de81b36'
[bcachefs-tools-debian] / tests / util.py
1 #!/usr/bin/python3
2
3 import os
4 import pytest
5 import re
6 import subprocess
7 import sys
8 import tempfile
9 import threading
10 import time
11
12 from pathlib import Path
13
14 DIR = Path('..')
15 BCH_PATH = DIR / 'bcachefs'
16
17 VPAT = re.compile(r'ERROR SUMMARY: (\d+) errors from (\d+) contexts')
18
19 ENABLE_VALGRIND = os.getenv('BCACHEFS_TEST_USE_VALGRIND', 'yes') == 'yes'
20
21 class ValgrindFailedError(Exception):
22     def __init__(self, log):
23         self.log = log
24
25 def check_valgrind(logfile):
26     log = logfile.read().decode('utf-8')
27     m = VPAT.search(log)
28     assert m is not None, 'Internal error: valgrind log did not match.'
29
30     errors = int(m.group(1))
31     if errors > 0:
32         raise ValgrindFailedError(log)
33
34 def run(cmd, *args, valgrind=False, check=False):
35     """Run an external program via subprocess, optionally with valgrind.
36
37     This subprocess wrapper will capture the stdout and stderr. If valgrind is
38     requested, it will be checked for errors and raise a
39     ValgrindFailedError if there's a problem.
40     """
41     cmds = [cmd] + list(args)
42     valgrind = valgrind and ENABLE_VALGRIND
43
44     if valgrind:
45         vout = tempfile.NamedTemporaryFile()
46         vcmd = ['valgrind',
47                '--leak-check=full',
48                '--log-file={}'.format(vout.name)]
49         cmds = vcmd + cmds
50
51     print("Running '{}'".format(cmds))
52     res = subprocess.run(cmds, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
53                          encoding='utf-8', check=check)
54
55     if valgrind:
56         check_valgrind(vout)
57
58     return res
59
60 def run_bch(*args, **kwargs):
61     """Wrapper to run the bcachefs binary specifically."""
62     cmds = [BCH_PATH] + list(args)
63     return run(*cmds, **kwargs)
64
65 def sparse_file(lpath, size):
66     """Construct a sparse file of the specified size.
67
68     This is typically used to create device files for bcachefs.
69     """
70     path = Path(lpath)
71     f = path.touch(mode = 0o600, exist_ok = False)
72     os.truncate(path, size)
73
74     return path
75
76 def device_1g(tmpdir):
77     """Default 1g sparse file for use with bcachefs."""
78     path = tmpdir / 'dev-1g'
79     return sparse_file(path, 1024**3)
80
81 def format_1g(tmpdir):
82     """Format a default filesystem on a 1g device."""
83     dev = device_1g(tmpdir)
84     run_bch('format', dev, check=True)
85     return dev
86
87 def mountpoint(tmpdir):
88     """Construct a mountpoint "mnt" for tests."""
89     path = Path(tmpdir) / 'mnt'
90     path.mkdir(mode = 0o700)
91     return path
92
93 class Timestamp:
94     '''Context manager to assist in verifying timestamps.
95
96     Records the range of times which would be valid for an encoded operation to
97     use.
98
99     FIXME: The kernel code is currently using CLOCK_REALTIME_COARSE, but python
100     didn't expose this time API (yet).  Probably the kernel shouldn't be using
101     _COARSE anyway, but this might lead to occasional errors.
102
103     To make sure this doesn't happen, we sleep a fraction of a second in an
104     attempt to guarantee containment.
105
106     N.B. this might be better tested by overriding the clock used in bcachefs.
107
108     '''
109     def __init__(self):
110         self.start = None
111         self.end = None
112
113     def __enter__(self):
114         self.start = time.clock_gettime(time.CLOCK_REALTIME)
115         time.sleep(0.1)
116         return self
117
118     def __exit__(self, type, value, traceback):
119         time.sleep(0.1)
120         self.end = time.clock_gettime(time.CLOCK_REALTIME)
121
122     def contains(self, test):
123         '''True iff the test time is within the range.'''
124         return self.start <= test <= self.end
125
126 class FuseError(Exception):
127     def __init__(self, msg):
128         self.msg = msg
129
130 class BFuse:
131     '''bcachefs fuse runner.
132
133     This class runs bcachefs in fusemount mode, and waits until the mount has
134     reached a point suitable for testing the filesystem.
135
136     bcachefs is run under valgrind by default, and is checked for errors.
137     '''
138
139     def __init__(self, dev, mnt):
140         self.thread = None
141         self.dev = dev
142         self.mnt = mnt
143         self.ready = threading.Event()
144         self.proc = None
145         self.returncode = None
146         self.stdout = None
147         self.stderr = None
148         self.vout = None
149
150     def run(self):
151         """Background thread which runs "bcachefs fusemount" under valgrind"""
152
153         vout = None
154         cmd = []
155
156         if ENABLE_VALGRIND:
157             vout = tempfile.NamedTemporaryFile()
158             cmd += [ 'valgrind',
159                      '--leak-check=full',
160                      '--log-file={}'.format(vout.name) ]
161
162         cmd += [ BCH_PATH,
163                  'fusemount', '-f', self.dev, self.mnt]
164
165         print("Running {}".format(cmd))
166
167         err = tempfile.TemporaryFile()
168         self.proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=err,
169                                      encoding='utf-8')
170
171         out1 = self.expect(self.proc.stdout, r'^Fuse mount initialized.$')
172         self.ready.set()
173
174         print("Waiting for process.")
175         (out2, _) = self.proc.communicate()
176         print("Process exited.")
177
178         self.stdout = out1 + out2
179         self.stderr = err.read()
180         self.returncode = self.proc.returncode
181         self.vout = vout
182
183     def expect(self, pipe, regex):
184         """Wait for the child process to mount."""
185
186         c = re.compile(regex)
187
188         out = ""
189         for line in pipe:
190             print('Expect line "{}"'.format(line.rstrip()))
191             out += line
192             if c.match(line):
193                 print("Matched.")
194                 return out
195
196         raise FuseError('stdout did not contain regex "{}"'.format(regex))
197
198     def mount(self):
199         print("Starting fuse thread.")
200
201         assert not self.thread
202         self.thread = threading.Thread(target=self.run)
203         self.thread.start()
204
205         self.ready.wait()
206         print("Fuse is mounted.")
207
208     def unmount(self, timeout=None):
209         print("Unmounting fuse.")
210         run("fusermount3", "-zu", self.mnt)
211         print("Waiting for thread to exit.")
212
213         self.thread.join(timeout)
214         if self.thread.is_alive():
215             self.proc.kill()
216             self.thread.join()
217
218         self.thread = None
219         self.ready.clear()
220
221         if self.vout:
222             check_valgrind(self.vout)
223
224     def verify(self):
225         assert self.returncode == 0
226         assert len(self.stdout) > 0
227         assert len(self.stderr) == 0
228
229 def have_fuse():
230     res = run(BCH_PATH, 'fusemount', valgrind=False)
231     return "Please supply a mountpoint." in res.stdout