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