#!/usr/bin/env python3 """Collect named diagnostics and render a browsable, static support report.""" import argparse from datetime import datetime, timezone import html import json import os from pathlib import Path import re import shlex import shutil import signal import subprocess import time class Report: def __init__(self, directory, timeout=300): self.directory = Path(directory).resolve() self.outputs = self.directory / 'outputs' self.outputs.mkdir(parents=True, exist_ok=False) self.timeout = timeout self.records = [] self.created = datetime.now(timezone.utc).isoformat() def run(self, name, command, stdin_path=None): filename = f'{len(self.records) + 1:03d}-{name}.txt' path = self.outputs / filename display = shlex.join(command) if stdin_path: display += ' < [filesystem passphrase file]' print(f'Collecting {filename}: {display}', flush=True) start = time.monotonic() status = 0 timed_out = False with path.open('wb') as output: source = None try: if stdin_path: source = open(stdin_path, 'rb') process = subprocess.Popen(command, cwd=self.directory, stdin=source or subprocess.DEVNULL, stdout=output, stderr=subprocess.STDOUT, start_new_session=True) try: status = process.wait(timeout=self.timeout) except subprocess.TimeoutExpired: timed_out = True os.killpg(process.pid, signal.SIGTERM) try: process.wait(timeout=5) except subprocess.TimeoutExpired: os.killpg(process.pid, signal.SIGKILL) process.wait() status = 124 output.write(f'\n[Timed out after {self.timeout:g} seconds]\n'.encode()) except BaseException: os.killpg(process.pid, signal.SIGKILL) process.wait() raise except OSError as error: status = 127 output.write(f'{error}\n'.encode()) finally: if source: source.close() record = {'file': f'outputs/{filename}', 'command': display, 'status': status, 'seconds': round(time.monotonic() - start, 2), 'timeout': timed_out, 'bytes': path.stat().st_size} self.records.append(record) return path def render(self): (self.directory / 'manifest.json').write_text(json.dumps({'created': self.created, 'commands': self.records}, indent=2) + '\n') with (self.directory / 'kent_report.log').open('wb') as full: full.write(f'Bcachefs support report\nCreated: {self.created}\n\nCommand index:\n'.encode()) for record in self.records: full.write(f"{record['file']}: {record['command']} [exit {record['status']}]\n".encode()) for record in self.records: full.write(f"\n\n{'=' * 80}\n$ {record['command']}\nFile: {record['file']}\nExit: {record['status']} | {record['seconds']}s\n\n".encode()) with (self.directory / record['file']).open('rb') as part: shutil.copyfileobj(part, full) entries = [] for record in self.records: link = html.escape(record['file'], quote=True) title = html.escape(record['command']) status = f"exit {record['status']} · {record['seconds']}s · {record['bytes']:,} bytes" entries.append(f'''
{title} {status}
Open to load output.
Open / download {link.split('/')[-1]}
''') page = ''' Bcachefs support report

Bcachefs support report

Created: CREATED. Expand a command to fetch its output. Each text file is also directly downloadable.

ENTRIES
'''.replace('CREATED', html.escape(self.created)).replace('ENTRIES', '\n'.join(entries)) (self.directory / 'index.html').write_text(page) # Static listing works even on hosts that disable automatic directory indexes. listing = 'Report files

Report files

Report cover page') def collect(report): report.run('date', ['date', '--iso-8601=seconds']) report.run('kernel', ['uname', '-a']) version_file = report.run('bcachefs-dkms-version', ['pacman', '-Q', 'bcachefs-dkms']) report.run('bcachefs-tools-version', ['pacman', '-Q', 'bcachefs-tools']) version = version_file.read_text(errors='replace').strip().split() if len(version) == 2 and version[0] == 'bcachefs-dkms': version = version[1].rsplit('-', 1)[0] match = re.fullmatch(r'(.+)\.r([0-9]+)\.g([0-9a-fA-F]+)', version) ref = f'v{match[1]}-{match[2]}-g{match[3]}' if match else f'v{version}' source = str(Path.home() / 'bcachefs-tools-git/src/bcachefs-tools') report.run('installed-commit', ['git', '-C', source, 'log', '--pretty=format:%h %ad %s [%cn]', '-n1', ref]) report.run('uninstalled-commits', ['git', '-C', source, 'log', '--pretty=format:%h %ad %s [%cn]', f'{ref}..master', '--', './libbcachefs/']) commands = [ ('lsblk', ['lsblk', '-e', '7', '-o', 'NAME,KNAME,TYPE,SIZE,ROTA,SCHED,HCTL,TRAN,MODEL,SERIAL,WWN,FSTYPE,MOUNTPOINTS']), ('numa', ['numactl', '--hardware']), ('pci', ['lspci', '-nn']), ('iostat', ['iostat', '-xz', '-m', '-y', '5', '12']), ('mpstat', ['mpstat', '-P', 'ALL', '5', '12']), ('vmstat', ['vmstat', '-w', '5', '12']), ('iotop', ['iotop', '-b', '-o', '-t', '-qqq', '-d', '5', '-n', '12']), ('reconcile', ['bcachefs', 'reconcile', 'status', '/mnt']), ('timestats', ['bcachefs', 'fs', 'timestats', '--once']), ('timestats-mean-recent', ['bcachefs', 'fs', 'timestats', '--once', '--sort', 'mean-recent']), ('timestats-dur-max', ['bcachefs', 'fs', 'timestats', '--once', '--sort', 'dur-max']), ('fs-top', ['bcachefs', 'fs', 'top', '--once', '/mnt']), ('fs-usage', ['bcachefs', 'fs', 'usage', '-h', '-a', '/mnt']), ('fs-top-sampled', ['bcachefs', 'fs', 'top', '-h', '-n', '60', '-d', '2', '/mnt']), ('trace-record', ['trace-cmd', 'record', '-e', 'bcachefs:copygc', '-e', 'bcachefs:reconcile_phys', '-e', 'bcachefs:data_update', 'sleep', '60']), ('trace-report', ['trace-cmd', 'report']), ] for name, command in commands: report.run(name, command) root = Path('/sys/fs/bcachefs') for pattern in ('*/*', '*/internal/*', '*/time_stats/*'): for path in sorted(root.glob(pattern)): if path.is_file(): name = 'sysfs-' + re.sub('[^a-zA-Z0-9_-]', '-', str(path.relative_to(root))) report.run(name, ['cat', str(path)]) report.run('iostat-instant', ['timeout', '1', 'sudo', '-n', 'iostat', '-x', '-m', '-d', '2']) report.run('dmesg', ['sudo', '-n', 'dmesg']) report.run('show-super', ['sudo', '-n', 'bcachefs', 'show-super', '/dev/sda'], stdin_path='/mnt/encryption_pw.txt') def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--output', type=Path, required=True) parser.add_argument('--timeout', type=float, default=300, help='Per-command timeout in seconds (default: 300)') args = parser.parse_args() if args.timeout <= 0: parser.error('--timeout must be positive') report = Report(args.output, args.timeout) try: collect(report) finally: report.render() print(f'Report saved to {report.directory}/index.html') if __name__ == '__main__': main()