Files

200 lines
10 KiB
Python

#!/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'''<article><details data-url="{link}"><summary>{title}
<span>{status}</span></summary><pre>Open to load output.</pre></details>
<a class="download" href="{link}">Open / download {link.split('/')[-1]}</a></article>''')
page = '''<!doctype html>
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Bcachefs support report</title>
<style>
body{font:16px system-ui,sans-serif;margin:2rem auto;padding:0 1rem;max-width:1100px;background:#f6f8fa;color:#18212b}
a{color:#075db3}nav{display:flex;gap:1.5rem;flex-wrap:wrap;margin:1.5rem 0}
article{background:white;border:1px solid #ccd5df;border-radius:6px;margin:12px 0;padding:12px}
summary{cursor:pointer;overflow-wrap:anywhere;font-family:monospace}summary span{display:block;color:#526273;font:13px system-ui;margin:8px 0}
pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:70vh;overflow:auto;background:#f0f3f6;padding:1rem}
.download{display:block;font-size:13px;margin-top:8px}input{padding:10px;width:95%;font:inherit}
</style>
<h1>Bcachefs support report</h1>
<p>Created: CREATED. Expand a command to fetch its output. Each text file is also directly downloadable.</p>
<nav><a href="kent_report.log">Full text log</a><a href="outputs/">Directory listing / individual files</a><a href="manifest.json">Command manifest (JSON)</a></nav>
<label>Filter commands <input id="filter" type="search" placeholder="dmesg, timestats, reconcile…"></label>
<noscript><p>JavaScript is disabled. Use the direct file links below or the directory listing.</p></noscript>
<main>ENTRIES</main>
<script>
document.querySelectorAll('details').forEach(details => {
details.addEventListener('toggle', async () => {
if (!details.open || details.dataset.loaded || details.dataset.loading) return;
details.dataset.loading = '1';
const output = details.querySelector('pre');
output.textContent = 'Loading…';
try {
const response = await fetch(details.dataset.url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
output.textContent = await response.text();
details.dataset.loaded = '1';
} catch (error) {
output.textContent = `Could not load output: ${error.message}. Close and reopen to retry, or use the direct link.`;
} finally { delete details.dataset.loading; }
});
});
document.querySelector('#filter').addEventListener('input', event => {
const query = event.target.value.toLowerCase();
document.querySelectorAll('article').forEach(article => {
article.hidden = !article.querySelector('summary').textContent.toLowerCase().includes(query)
&& !article.querySelector('a').textContent.toLowerCase().includes(query);
});
});
</script></html>
'''.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 = '<!doctype html><meta charset="utf-8"><title>Report files</title><h1>Report files</h1><a href="../">Report cover page</a><ul>'
for record in self.records:
name = html.escape(Path(record['file']).name, quote=True)
listing += f'<li><a href="{name}">{name}</a> ({record["bytes"]:,} bytes)</li>'
(self.outputs / 'index.html').write_text(listing + '</ul>')
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()