200 lines
10 KiB
Python
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()
|