blob: d8ca8aa476c281dd29968ebbe7bcff61e3dcb3f5 [file] [log] [blame]
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# Copyright (C) 2017-2020 The Project X-Ray Authors.
#
# Use of this source code is governed by a ISC-style
# license that can be found in the LICENSE file or at
# https://opensource.org/licenses/ISC
#
# SPDX-License-Identifier: ISC
from datetime import datetime, timedelta
import argparse
import collections
import io
import os
import os.path
import re
import resource
import signal
import subprocess
import sys
import traceback
from utils.create_environment import get_environment_variables
import junit_xml
secs_in_min = 60
secs_in_hour = 60 * secs_in_min
def pretty_timedelta_str(d):
"""Pretty print a time delta object.
>>> pretty_timedelta_str(timedelta(seconds=2))
'2s'
>>> pretty_timedelta_str(timedelta(seconds=61))
'1m01s'
>>> pretty_timedelta_str(timedelta(seconds=125))
'2m05s'
>>> pretty_timedelta_str(timedelta(seconds=secs_in_hour * 5))
'5h00m'
Once we get to displaying hours, we don't bother displaying seconds.
>>> pretty_timedelta_str(timedelta(seconds=secs_in_hour * 5 + 1))
'5h00m'
>>> pretty_timedelta_str(timedelta(
... seconds=secs_in_hour * 5 + secs_in_min * 2 + 1))
'5h02m'
>>> pretty_timedelta_str(timedelta(seconds=secs_in_hour * 5.5))
'5h30m'
"""
assert isinstance(d, timedelta)
ts = d.total_seconds()
assert ts > 0, (d, ts)
bits = []
hours = int(ts / secs_in_hour)
if hours > 0:
bits.append("{}h".format(hours))
ts -= hours * secs_in_hour
mins = int(ts / secs_in_min)
if mins > 0 or hours > 0:
if hours > 0:
bits.append("{:02d}m".format(mins))
else:
bits.append("{:d}m".format(mins))
ts -= mins * secs_in_min
if hours == 0:
if mins > 0:
bits.append("{:02d}s".format(int(ts)))
else:
bits.append("{:d}s".format(int(ts)))
assert len(bits) > 0, d
return "".join(bits)
class Logger:
"""Output a bunch of lines with a prefix.
>>> l = Logger("fuzz", datetime(2001, 11, 1), 6)
>>> l._now = lambda: datetime(2001, 11, 1, second=10)
>>> l.log("Test!")
2001-11-01T00:00:10 - fuzz - 10s: Test!
>>> l.log("Format {} {t}", [1,], {'t': 2})
2001-11-01T00:00:10 - fuzz - 10s: Format 1 2
>>> l.log('''\\
... Line 1
... Line 2
... Line 3
... ''')
2001-11-01T00:00:10 - fuzz - 10s: Line 1
2001-11-01T00:00:10 - fuzz - 10s: Line 2
2001-11-01T00:00:10 - fuzz - 10s: Line 3
"""
def __init__(self, fuzzer, time_start, padding):
self.fuzzer = fuzzer + ' ' * (padding - len(fuzzer))
self.time_start = time_start
self.queue = collections.deque()
def _now(self):
return datetime.utcnow()
def log(self, msg, args=None, kw=None, flush=True):
"""Queue a log message.
This is safe to do in a signal handler where you can't do io.
"""
if args is None:
args = []
if kw is None:
kw = {}
time_log = self._now()
self.queue.append((time_log, msg, args, kw))
if flush:
self.flush()
def flush(self):
while len(self.queue) > 0:
time_log, msg, args, kw = self.queue.popleft()
self._output(time_log, msg, args, kw)
def _output(self, time_log, msg, args, kw):
running_for = time_log - self.time_start
msg = msg.format(*args, **kw)
time_log = time_log.replace(microsecond=0)
log_prefix = "{:s} - {}/{:s} - {:>5s}: ".format(
time_log.isoformat(),
os.environ['XRAY_PART'],
self.fuzzer,
pretty_timedelta_str(running_for),
)
msg = "\n".join(log_prefix + x for x in msg.splitlines())
print(msg, flush=True)
def last_lines(f, num, blocksize=100):
r"""Return n last lines in a file.
>>> f = io.StringIO("")
>>> last_lines(f, 100)
['']
>>> f = io.StringIO("1\n2")
>>> last_lines(f, 100)
['1', '2']
>>> f = io.StringIO("11\n22\n33\n44\n55")
>>> last_lines(f, 100)
['11', '22', '33', '44', '55']
>>> f = io.StringIO("11\n22\n33\n44\n55")
>>> last_lines(f, 100, 2)
['11', '22', '33', '44', '55']
>>> f = io.StringIO("11\n22\n33\n44\n55")
>>> last_lines(f, 100, 3)
['11', '22', '33', '44', '55']
>>> f = io.StringIO("11\n22\n33\n44\n55")
>>> last_lines(f, 2, 2)
['44', '55']
>>> f = io.StringIO("11\n22\n33\n44\n55")
>>> last_lines(f, 2, 3)
['44', '55']
>>> f = io.StringIO("11\n22\n33\n44\n55")
>>> last_lines(f, 2, 4)
['44', '55']
>>> f = io.StringIO("1\n22\n3333333\n")
>>> last_lines(f, 100, 3)
['1', '22', '3333333', '']
"""
lines = collections.deque([''])
# Seek to the end of the file
f.seek(0, os.SEEK_END)
fpos = f.tell()
while len(lines) < (num + 1) and fpos > 0:
bs = min(blocksize, fpos)
fpos = fpos - bs
f.seek(fpos)
data = f.read(bs)
while True:
lpos = data.rfind('\n')
lines[0] = data[lpos + 1:] + lines[0]
if lpos == -1:
break
lines.insert(0, '')
data = data[:lpos]
if fpos == 0:
lines.insert(0, '')
return list(lines)[1:]
def get_usage():
# This function only works if you have a signal handler for the
# signal.SIGCHLD signal.
raw_usage = resource.getrusage(resource.RUSAGE_CHILDREN)
# 0 ru_utime time in user mode (float)
# 1 ru_stime time in system mode (float)
# 2 ru_maxrss maximum resident set size
#
# These fields are always zero on Linux
# 3 ru_ixrss shared memory size
# 4 ru_idrss unshared memory size
# 5 ru_isrss unshared stack size
return "User:{}s System:{}s".format(
int(raw_usage.ru_utime),
int(raw_usage.ru_stime),
)
def get_load():
"""Return current load average.
Values is average perecntage of CPU used over 1 minute, 5 minutes, 15
minutes.
"""
a1, a5, a15 = os.getloadavg()
cpus = os.cpu_count()
return a1 / cpus * 100.0, a5 / cpus * 100.0, a15 / cpus * 100.0
class PsTree:
_pstree_features = None
@classmethod
def get_features(cls):
if cls._pstree_features is None:
cls._pstree_features = []
for f in ['-T', '-l']:
try:
subprocess.check_output(
"pstree {}".format(f),
stderr=subprocess.STDOUT,
shell=True,
)
cls._pstree_features.append(f)
except subprocess.CalledProcessError:
continue
return cls._pstree_features
@classmethod
def get(cls, pid):
"""Get processes under current one.
Requires the pstree until be installed, otherwise returns empty string.
"""
p = subprocess.Popen(
"pstree {} {}".format(" ".join(cls.get_features()), pid),
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
stdin=subprocess.DEVNULL,
universal_newlines=True,
)
stdout, stderr = p.communicate()
# Special case segfaults.
if 'Segmentation fault' in stderr:
stderr = ''
stdout = '(result too big, pstree segfaulted.)'
# If no error, just return stdout
if not stderr:
return stdout
return "{}\n{}".format(stdout, stderr)
def mem_convert(s):
"""
>>> mem_convert('62G')
62000000000.0
>>> mem_convert('62M')
62000000.0
>>> mem_convert('62B')
62.0
"""
units = {
'Gi': pow(2, 30),
'Mi': pow(2, 20),
'Ki': pow(2, 10),
'G': 1e9,
'M': 1e6,
'K': 1e3,
'B': 1,
}
u = '',
m = 1
for u, m in units.items():
if s.endswith(u):
break
v = float(s[:-len(u)])
v = v * m
return v
def get_memory(memstr=None):
r"""
>>> import pprint
>>> pprint.pprint(get_memory('''\
... total used free shared buff/cache available
... Mem: 62G 19G 4.8G 661M 38G 42G
... Swap: 0B 0B 0B
... '''))
{'mem': {'available': 42000000000.0,
'buff/cache': 38000000000.0,
'free': 4800000000.0,
'shared': 661000000.0,
'total': 62000000000.0,
'used': 19000000000.0},
'swap': {'free': 0.0, 'total': 0.0, 'used': 0.0}}
"""
if memstr is None:
memstr = subprocess.check_output(
'free -mh', shell=True).decode("utf-8")
lines = [x.split() for x in memstr.strip().splitlines()]
lines[0].insert(0, 'type:')
for l in lines:
l[0] = l[0][:-1].lower()
headers = lines[0][1:]
lines = lines[1:]
memory = {}
for l in lines:
t, l = l[0], l[1:]
d = {}
for k, v in zip(headers, l):
d[k] = mem_convert(v)
memory[t] = d
return memory
def should_run_submake(make_flags):
"""Check if make_flags indicate that we should execute things.
See https://www.gnu.org/software/make/manual/html_node/Instead-of-Execution.html#Instead-of-Execution # noqa
If this is a dry run or question then we shouldn't execute or output
anything.
The flags end up as single letter versions in the MAKEFLAGS environment
variable.
>>> should_run_submake('')
True
The following flags are important;
-n == --dry-run
>>> should_run_submake('n')
False
>>> should_run_submake('n --blah')
False
>>> should_run_submake('--blah n')
False
>>> should_run_submake('--blah')
True
>>> should_run_submake('--random')
True
-q == --question
>>> should_run_submake('q')
False
>>> should_run_submake('q --blah')
False
>>> should_run_submake('--blah q')
False
>>> should_run_submake('--blah')
True
>>> should_run_submake('--random')
True
Both --dry-run and --question
>>> should_run_submake('qn')
False
>>> should_run_submake('nq')
False
>>> should_run_submake('--quiant')
True
"""
r = re.search(r'(?:^|\s)[^-]*(n|q)[^\s]*(\s|$)', make_flags)
if not r:
return True
return not bool(r.groups()[0])
def main(argv):
fuzzers_dir = os.path.abspath(os.path.dirname(__file__))
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("fuzzer", help="fuzzer to run")
parser.add_argument(
"--retries",
type=int,
default=0,
help="Retry a failed fuzzer n times.",
)
args = parser.parse_args()
# Setup the logger with flush=False, it should be safe to use in a signal
# handler.
fuzzer_length = max(len(f) for f in os.listdir(fuzzers_dir))
logger = Logger(args.fuzzer, datetime.utcnow(), fuzzer_length)
# Need a signal handler on SIGCHLD otherwise get_resource doesn't return
# anything.
signal.signal(signal.SIGCHLD, lambda sig, frame: None)
fuzzer_dir = os.path.join(fuzzers_dir, args.fuzzer)
assert os.path.exists(fuzzer_dir), fuzzer_dir
fuzzer_logdir = os.path.join(
fuzzer_dir, "logs_{}".format(os.environ['XRAY_PART']))
if not os.path.exists(fuzzer_logdir):
os.makedirs(fuzzer_logdir)
assert os.path.exists(fuzzer_logdir)
# Update the environment for a specific part
environment = get_environment_variables()
for key, value in environment.items():
os.environ[key] = value
exit_code = -1
args.retries += 1
for retry_count in range(0, args.retries):
logger.log('Running fuzzer attempt: {}', [retry_count])
exit_code = run_fuzzer(
args.fuzzer,
fuzzer_dir,
fuzzer_logdir,
logger,
will_retry=retry_count < (args.retries - 1),
)
if exit_code <= 0:
break
logger.log('WARNING: Fuzzer failed!')
return exit_code
def run_fuzzer(fuzzer_name, fuzzer_dir, fuzzer_logdir, logger, will_retry):
def log(msg, *a, **k):
logger.log(msg, a, k, flush=True)
make_cmd = os.environ.get('MAKE', 'make')
make_flags = os.environ.get('MAKEFLAGS', '')
# Should run things?
if not should_run_submake(make_flags):
return 0
fuzzer_runok = os.path.join(
fuzzer_dir, "run.{}.ok".format(os.environ['XRAY_PART']))
if os.path.exists(fuzzer_runok):
last_modified = datetime.fromtimestamp(os.stat(fuzzer_runok).st_mtime)
log(
"Skipping as run.{}.ok exists (updated @ {})",
os.environ['XRAY_PART'], last_modified.isoformat())
return 0
fuzzer_runok = os.path.join(fuzzer_dir, "run.ok")
if os.path.exists(fuzzer_runok):
last_modified = datetime.fromtimestamp(os.stat(fuzzer_runok).st_mtime)
log(
"Skipping as run.ok exists (updated @ {})",
last_modified.isoformat())
return 0
time_start = datetime.utcnow()
log("Starting @ {}", time_start.isoformat())
running_msg = "Running {} -C {} run (with MAKEFLAGS='{}')".format(
make_cmd,
fuzzer_dir,
make_flags,
)
log(running_msg)
log_suffix = ".{}.log".format(time_start.isoformat()).replace(":", "-")
fuzzer_stdout = os.path.join(fuzzer_logdir, "stdout" + log_suffix)
fuzzer_stderr = os.path.join(fuzzer_logdir, "stderr" + log_suffix)
# Write header to stdout/stderr to make sure they match.
for fname in [fuzzer_stdout, fuzzer_stderr]:
with open(fname, "w") as fd:
fd.write("Build starting @ {}\n".format(time_start.isoformat()))
fd.write(running_msg)
fd.write("\n")
fd.write("-" * 75)
fd.write("\n")
fd.flush()
os.fsync(fd)
# Open the log files for appending
stdout_fd = open(fuzzer_stdout, "a")
stderr_fd = open(fuzzer_stderr, "a")
# Play nice with make's jobserver.
# See https://www.gnu.org/software/make/manual/html_node/POSIX-Jobserver.html#POSIX-Jobserver # noqa
job_fds = []
if '--jobserver-fds' in make_flags or '--jobserver-auth' in make_flags:
job_re = re.search(
'--jobserver-(?:fds|auth)=([0-9]+),([0-9]+)', make_flags)
assert job_re, make_flags
job_rd, job_wr = job_re.groups()
# Make copies of jobserver FDs in case a retry is needed.
job_rd = int(job_rd)
job_wr = int(job_wr)
assert job_rd > 2, (job_rd, job_wr, make_flags)
assert job_wr > 2, (job_rd, job_wr, make_flags)
job_fds.append(job_rd)
job_fds.append(job_wr)
p = None
try:
p = subprocess.Popen(
[make_cmd, '-C', fuzzer_dir, 'run'],
stdin=None,
stdout=stdout_fd,
stderr=stderr_fd,
pass_fds=job_fds,
)
while True:
try:
retcode = p.wait(timeout=10)
p = None
except subprocess.TimeoutExpired:
retcode = None
if retcode is not None:
break
mem = get_memory()['mem']
log(
"Still running (1m:{:0.2f}%, 5m:{:0.2f}%, 15m:{:0.2f}% Mem:{:0.1f}Gi used, {:0.1f}Gi free).\n{}",
*get_load(),
mem['used'] / 1e9,
mem['available'] /
1e9, # Using available so the numbers add up.
PsTree.get(p.pid),
)
except (Exception, KeyboardInterrupt, SystemExit):
retcode = -1
tb = io.StringIO()
traceback.print_exc(file=tb)
log(tb.getvalue())
# Prevent Ctrl-C so we exit properly...
old_sigint_handler = signal.getsignal(signal.SIGINT)
def log_die(sig, frame):
logger.log("Dieing!")
signal.signal(signal.SIGINT, log_die)
# Cleanup child process if they haven't already died.
try:
if p is not None:
try:
retcode = p.wait(1)
except subprocess.TimeoutExpired:
retcode = -1
p.kill()
p.wait()
log("Warning: Killed program which should have been dead!")
except Exception:
tb = io.StringIO()
traceback.print_exc(file=tb)
log(tb.getvalue())
# Wait for all children to finish.
try:
while True:
log("Child finished: {}", os.waitpid(-1, 0))
except ChildProcessError:
pass
log("Finishing ({}).", get_usage())
time_end = datetime.utcnow()
error_log = "\n".join(last_lines(open(fuzzer_stderr), 10000))
success_log = "\n".join(last_lines(open(fuzzer_stdout), 100))
# Find the next X_sponge_log.xml file name...
for i in range(0, 100):
tsfilename = os.path.join(fuzzer_logdir, '{}_sponge_log.xml'.format(i))
if not os.path.exists(tsfilename):
break
test_case = junit_xml.TestCase(
name=fuzzer_name,
timestamp=time_start.timestamp(),
elapsed_sec=(time_end - time_start).total_seconds(),
stdout=success_log,
stderr=error_log,
)
if retcode != 0:
test_case.add_failure_info(
'Fuzzer failed with exit code: {}'.format(retcode))
with open(tsfilename, 'w') as f:
ts = junit_xml.TestSuite(fuzzer_name, [test_case])
junit_xml.TestSuite.to_file(f, [ts])
if retcode != 0:
test_case.add_failure_info(
'Fuzzer failed with exit code: {}'.format(retcode), )
# Log the last 10,000 lines of stderr on a failure
log(
"""\
--------------------------------------------------------------------------
!Failed! @ {time_end} with exit code: {retcode}
--------------------------------------------------------------------------
- STDOUT (see {stdout_fname} for full log):
--------------------------------------------------------------------------
{stdout_log}
--------------------------------------------------------------------------
- STDERR (see {stderr_fname} for full log):
--------------------------------------------------------------------------
{stderr_log}
--------------------------------------------------------------------------
!Failed! @ {time_end} with exit code: {retcode}
--------------------------------------------------------------------------
""",
retcode=retcode,
stdout_fname=fuzzer_stdout,
stdout_log='\n'.join(last_lines(open(fuzzer_stdout), 1000)),
stderr_fname=fuzzer_stderr,
stderr_log='\n'.join(last_lines(open(fuzzer_stderr), 1000)),
time_end=time_end.isoformat())
else:
# Log the last 100 lines of a successful run
log(
"""\
Succeeded! @ {}
--------------------------------------------------------------------------
{}
--------------------------------------------------------------------------
Succeeded! @ {}
""", time_end.isoformat(), success_log, time_end.isoformat())
logger.flush()
signal.signal(signal.SIGINT, old_sigint_handler)
return retcode
if __name__ == "__main__":
if "--test" in sys.argv or len(sys.argv) == 1:
import doctest
doctest.testmod()
else:
sys.exit(main(sys.argv))