Publish browsable support reports with separate command outputs
This commit is contained in:
1 parent
88f76a6874
commit
c27f64dc3d
5 files changed
+491
-137
No files matched your search
@@ -75,30 +75,109 @@ Add the desired inputs to your Telegraf TOML configuration (local or hosted):
|
||||
|
||||
## Support reports and pastes
|
||||
|
||||
Run `./kent_report.sh` from the directory where you want `kent_report.log` and
|
||||
trace output saved. It performs the existing lengthy diagnostics and uploads
|
||||
the report to the paste server, printing a URL for manually sharing on IRC.
|
||||
It does not send an IRC message. The diagnostics retain the original host's
|
||||
Arch packages, bcachefs source checkout, device and encryption-file paths;
|
||||
review those and the collected information before running on another host.
|
||||
Run `./kent_report.sh` as your regular user (with kubectl access) from the directory
|
||||
where you want a new `kent-report-DATE-RANDOM/` directory. It runs the existing
|
||||
lengthy diagnostics and uploads a completed report, printing its public cover-page
|
||||
URL for manually sharing on IRC. It does not send an IRC message.
|
||||
|
||||
`paste_log.sh` contains the shared function and can also be run directly:
|
||||
Each report includes:
|
||||
|
||||
- `index.html`: a searchable cover page with collapsible command outputs. Output
|
||||
text is fetched only when expanded and inserted as plain text, never HTML.
|
||||
- `outputs/`: a public file listing with descriptive, numbered `.txt` files for
|
||||
each command, including separate dmesg, timestats, reconcile and sysfs files.
|
||||
Direct links work with browsers, curl and wget, including without JavaScript.
|
||||
- `kent_report.log`: the complete combined text report with a command index.
|
||||
- `manifest.json`: command lines, filenames, exit statuses, durations and sizes.
|
||||
|
||||
Both stdout and stderr are captured. A failed command does not abort the rest.
|
||||
Commands time out after 300 seconds by default; the report records timeouts and
|
||||
exit statuses. The original Arch packages, source checkout, `/mnt`, `/dev/sda`
|
||||
and encryption-file paths are retained. Raw `trace.dat` stays in the local report
|
||||
directory; only the rendered report files are uploaded. Report directories must
|
||||
be new (or empty); existing outputs are never overwritten.
|
||||
|
||||
```sh
|
||||
./paste_log.sh path/to/log
|
||||
./kent_report.sh # collect and upload
|
||||
./kent_report.sh --no-upload # collect locally for review
|
||||
./kent_report.sh --output ./my-report --timeout 600 --no-upload
|
||||
source ./paste_log.sh
|
||||
paste_directory ./my-report # upload an already collected report
|
||||
```
|
||||
|
||||
To make `paste_log` available in interactive Bash, add:
|
||||
The upload is copied to a temporary remote directory and renamed after success,
|
||||
so the printed URL points at a complete report. Apache configuration changes are
|
||||
not required; the report supplies its own static directory listing.
|
||||
|
||||
`paste_log.sh` still provides `paste_log path/to/log` for single-file uploads,
|
||||
and can be executed directly as `./paste_log.sh path/to/log`. To expose both
|
||||
helpers in interactive Bash:
|
||||
|
||||
```bash
|
||||
source "$HOME/telegraf-bcachefs-input/paste_log.sh"
|
||||
```
|
||||
|
||||
`kent_report.sh` sources the same file relative to its own location; it does not
|
||||
rely on interactive shell functions being exported. Uploads use the current
|
||||
kubectl context/namespace and require exactly one pod named `brunner-ninja*`.
|
||||
Failed uploads return an error without printing a misleading URL. Temporary
|
||||
name reservations are cleaned up, and function errors do not exit your shell.
|
||||
`kent_report.sh` sources this same file relative to its own location. Uploads use
|
||||
the current kubectl context/namespace and require exactly one `brunner-ninja*`
|
||||
pod. Failed uploads return an error without printing a URL. Helper errors do not
|
||||
exit an interactive shell.
|
||||
|
||||
## Home Assistant switches
|
||||
|
||||
`bcachefs_mqtt.py` publishes MQTT discovery for **Reconcile** and **Copy GC**,
|
||||
grouped under the host's Bcachefs device. On the original host the entities are
|
||||
`switch.bcachefs_reconcile_enabled` and `switch.bcachefs_copygc_enabled`.
|
||||
|
||||
The bridge reads `reconcile_enabled` and `copygc_enabled` from sysfs every two
|
||||
seconds, including changes made from the command line. Home Assistant commands
|
||||
write only these two options and then read back actual state; the UI is not
|
||||
optimistic. Discovery and state messages are retained, commands are not, and
|
||||
retained commands are rejected. No shell commands are executed from MQTT input.
|
||||
|
||||
Missing or unreadable options make their switches unavailable on the next poll.
|
||||
A clean service stop publishes offline immediately; MQTT's retained last will
|
||||
handles a crash or connection loss (network-loss detection can take about 23
|
||||
seconds with the 15-second keepalive). On remount, reconnect, or a Home Assistant
|
||||
birth message the bridge republishes state. It never restores a previous option
|
||||
value automatically: the mounted filesystem is always the source of truth.
|
||||
|
||||
Install from a stable checkout path without whitespace:
|
||||
|
||||
```sh
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements-mqtt.txt
|
||||
sudo ./install-mqtt.sh
|
||||
```
|
||||
|
||||
The installer creates `/etc/default/bcachefs-mqtt` from the example only if it
|
||||
is absent, installs the systemd service, and enables/restarts it. Configure
|
||||
`MQTT_HOST`, `BCACHEFS_UUID`, and optional credentials/TLS there, outside Git.
|
||||
It defaults to the original host's broker and filesystem UUID. The service runs
|
||||
as root to write sysfs, with its filesystem read-only except `/sys/fs/bcachefs`.
|
||||
It starts at boot even if the filesystem is not yet mounted.
|
||||
|
||||
```sh
|
||||
sudo systemctl restart bcachefs-mqtt # after bridge/config edits
|
||||
systemctl status bcachefs-mqtt
|
||||
journalctl -u bcachefs-mqtt
|
||||
```
|
||||
|
||||
MQTT topics are `bcachefs/UUID/OPTION/state` and `/set`, with `0`/`1` payloads.
|
||||
Discovery uses `homeassistant/switch/bcachefs_UUID/OPTION/config`. To uninstall,
|
||||
stop/disable the service and clear its two retained discovery config topics.
|
||||
|
||||
The integration follows [Home Assistant MQTT switch discovery and availability](https://www.home-assistant.io/integrations/switch.mqtt/)
|
||||
and uses the [Paho MQTT Python client](https://eclipse.dev/paho/clients/python/docs/).
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
python3 tests/test_helpers.py
|
||||
.venv/bin/python -m unittest discover -s tests -p 'test_mqtt_report.py' -v
|
||||
```
|
||||
|
||||
Tests use temporary option files and stub uploads; they do not toggle a real
|
||||
filesystem or publish a real diagnostic report.
|
||||
|
||||
## Building the performance collector
|
||||
|
||||
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
#!/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()
|
||||
+23
-123
@@ -1,126 +1,26 @@
|
||||
#!/bin/bash
|
||||
#set -euo pipefail
|
||||
|
||||
# Resolve the checkout even when invoked through a symlink.
|
||||
set -euo pipefail
|
||||
REPORT_DIR=$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")
|
||||
source "$REPORT_DIR/paste_log.sh" || exit 1
|
||||
|
||||
(
|
||||
|
||||
echo bcachefs support reporter for py1hon 1.0
|
||||
echo Date: $(date)
|
||||
echo Kernel: $(uname -a)
|
||||
|
||||
ver="$(pacman -Q bcachefs-dkms)"
|
||||
echo "$ver"
|
||||
|
||||
if [[ "$ver" =~ ^([^[:space:]]+)[[:space:]]+(.+)$ ]] ; then
|
||||
ver="${BASH_REMATCH[2]}"
|
||||
ver="${ver%-*}"
|
||||
|
||||
# Convert Arch git-style version to git describe format
|
||||
if [[ "$ver" =~ ^(.+)\.r([0-9]+)\.g([0-9a-fA-F]+)$ ]]; then
|
||||
tag="${BASH_REMATCH[1]}"
|
||||
rev="${BASH_REMATCH[2]}"
|
||||
hash="${BASH_REMATCH[3]}"
|
||||
ver="v${tag}-${rev}-g${hash}"
|
||||
else
|
||||
# Plain tag (no commits on top)
|
||||
ver="v${ver}"
|
||||
fi
|
||||
|
||||
COMMIT_MESSAGE="$(git -C ~/bcachefs-tools-git/src/bcachefs-tools/ log --pretty=format:"%h %ad %s [%cn]" -n1 "$ver")"
|
||||
if [[ $? -eq 0 ]]
|
||||
then
|
||||
echo Installed commit: "$COMMIT_MESSAGE"
|
||||
echo
|
||||
echo "Commits in master that are not installed on this system (yet? is this worth a reboot?):"
|
||||
git -C ~/bcachefs-tools-git/src/bcachefs-tools log --pretty=format:"%h %ad %s [%cn]" "${ver}..master" -- ./libbcachefs/
|
||||
fi
|
||||
else
|
||||
echo "parsing arch package version failed: $ver"
|
||||
exit 1
|
||||
source "$REPORT_DIR/paste_log.sh"
|
||||
output=
|
||||
upload=1
|
||||
args=()
|
||||
while (($#)); do
|
||||
case $1 in
|
||||
--output) [[ $# -ge 2 ]] || exit 2; output=$2; shift 2 ;;
|
||||
--no-upload) upload=0; shift ;;
|
||||
--timeout) [[ $# -ge 2 ]] || exit 2; args+=("--timeout" "$2"); shift 2 ;;
|
||||
-h|--help)
|
||||
echo "Usage: $0 [--output DIRECTORY] [--no-upload] [--timeout SECONDS]"
|
||||
echo 'Collect diagnostics, build an HTML cover page and individual text files, then upload.'
|
||||
exit 0 ;;
|
||||
*) echo "Unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
if [[ -z $output ]]; then
|
||||
output=$(mktemp -d "./kent-report-$(date +%Y%m%d-%H%M%S)-XXXXXXXX")
|
||||
fi
|
||||
python3 "$REPORT_DIR/kent_report.py" --output "$output" "${args[@]}"
|
||||
if ((upload)); then
|
||||
paste_directory "$output"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo
|
||||
|
||||
pacman -Q bcachefs-tools
|
||||
|
||||
echo
|
||||
echo
|
||||
echo
|
||||
|
||||
function print_cmd()
|
||||
{
|
||||
if [[ -z "$INDEX_ONLY" ]]
|
||||
then
|
||||
echo
|
||||
echo
|
||||
echo
|
||||
fi
|
||||
echo "\$" "${@}"
|
||||
}
|
||||
|
||||
function run_cmd()
|
||||
{
|
||||
print_cmd "${@}"
|
||||
if [[ -z "$INDEX_ONLY" ]]
|
||||
then
|
||||
"${@}"
|
||||
fi
|
||||
}
|
||||
|
||||
function do_work()
|
||||
{
|
||||
run_cmd lsblk -e 7 -o NAME,KNAME,TYPE,SIZE,ROTA,SCHED,HCTL,TRAN,MODEL,SERIAL,WWN,FSTYPE,MOUNTPOINTS
|
||||
run_cmd numactl --hardware
|
||||
run_cmd lspci -nn
|
||||
run_cmd iostat -xz -m -y 5 12
|
||||
run_cmd mpstat -P ALL 5 12
|
||||
run_cmd vmstat -w 5 12
|
||||
run_cmd iotop -b -o -t -qqq -d 5 -n 12
|
||||
run_cmd bcachefs reconcile status /mnt
|
||||
run_cmd bcachefs fs timestats --once
|
||||
run_cmd bcachefs fs timestats --once --sort mean-recent
|
||||
run_cmd bcachefs fs timestats --once --sort dur-max
|
||||
run_cmd bcachefs fs top --once /mnt
|
||||
run_cmd bcachefs fs usage -h -a /mnt
|
||||
run_cmd bcachefs fs top -h -n 60 -d 2 /mnt
|
||||
run_cmd trace-cmd record -e bcachefs:copygc -e bcachefs:reconcile_phys -e bcachefs:data_update sleep 60
|
||||
run_cmd trace-cmd report
|
||||
|
||||
for i in /sys/fs/bcachefs/*/* /sys/fs/bcachefs/*/internal/* /sys/fs/bcachefs/*/time_stats/*
|
||||
do
|
||||
if [[ -f "$i" ]]
|
||||
then
|
||||
run_cmd cat $i
|
||||
fi
|
||||
done
|
||||
|
||||
print_cmd iostat -x -m -d 2
|
||||
if [[ -z "$INDEX_ONLY" ]]
|
||||
then
|
||||
timeout 1 sudo iostat -x -m -d 2
|
||||
fi
|
||||
|
||||
run_cmd sudo dmesg
|
||||
|
||||
# super slow since a few months (>5min)
|
||||
# run_cmd sudo bcachefs fs usage -f devices,rebalance_work,compression,btree /mnt
|
||||
|
||||
print_cmd sudo bcachefs show-super /dev/sda
|
||||
if [[ -z "$INDEX_ONLY" ]]
|
||||
then
|
||||
cat /mnt/encryption_pw.txt | sudo bcachefs show-super /dev/sda
|
||||
fi
|
||||
}
|
||||
|
||||
echo Dump-Index: The following commands will be printed in this order:
|
||||
INDEX_ONLY=1 do_work
|
||||
|
||||
do_work
|
||||
|
||||
) | tee kent_report.log
|
||||
|
||||
paste_log kent_report.log
|
||||
@@ -33,6 +33,41 @@ paste_log()
|
||||
printf 'https://brunner.ninja/pastes/%s\n' "$name"
|
||||
}
|
||||
|
||||
# Upload a completed report under an unpredictable directory name. The remote
|
||||
# rename makes the published URL visible only after the entire copy succeeds.
|
||||
paste_directory()
|
||||
{
|
||||
if [[ $# -ne 1 || ! -f $1/index.html || ! -f $1/kent_report.log || ! -d $1/outputs ]]; then
|
||||
echo 'Usage: paste_directory <report-directory>' >&2
|
||||
return 2
|
||||
fi
|
||||
local pods pod source temporary name status
|
||||
local -a matches=()
|
||||
pods=$(kubectl get pods -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}') || return
|
||||
while IFS= read -r pod; do
|
||||
[[ $pod == brunner-ninja* ]] && matches+=("$pod")
|
||||
done <<< "$pods"
|
||||
if [[ ${#matches[@]} -ne 1 ]]; then
|
||||
echo "Expected one brunner-ninja pod, found ${#matches[@]}" >&2
|
||||
return 1
|
||||
fi
|
||||
source=$(realpath -- "$1") || return
|
||||
temporary=$(mktemp -d "${TMPDIR:-/tmp}/kent-report-XXXXXXXXXXXX") || return
|
||||
name=${temporary##*/}
|
||||
# Only publish report artifacts, not raw trace.dat or unrelated local files.
|
||||
if cp -R -- "$source/index.html" "$source/kent_report.log" "$source/manifest.json" "$source/outputs" "$temporary/" &&
|
||||
chmod -R a+rX "$temporary" &&
|
||||
kubectl cp "$temporary" "${matches[0]}:/srv/http/pastes/.$name.uploading" &&
|
||||
kubectl exec "${matches[0]}" -- mv -- "/srv/http/pastes/.$name.uploading" "/srv/http/pastes/$name"; then
|
||||
status=0
|
||||
else
|
||||
status=$?
|
||||
fi
|
||||
rm -rf -- "$temporary"
|
||||
((status == 0)) || return "$status"
|
||||
printf 'https://brunner.ninja/pastes/%s/\n' "$name"
|
||||
}
|
||||
|
||||
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
|
||||
paste_log "$@"
|
||||
fi
|
||||
@@ -0,0 +1,141 @@
|
||||
import os
|
||||
import subprocess
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from bcachefs_mqtt import Bridge, OPTIONS
|
||||
from kent_report import Report
|
||||
|
||||
UUID = '3d3a0763-4dfe-41e6-93c1-8c791ec98176'
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self):
|
||||
self.messages = []
|
||||
self.subscriptions = []
|
||||
|
||||
def publish(self, topic, payload, **kwargs):
|
||||
self.messages.append((topic, payload))
|
||||
|
||||
def subscribe(self, topic, **kwargs):
|
||||
self.subscriptions.append(topic)
|
||||
|
||||
def will_set(self, topic, payload, **kwargs):
|
||||
self.will = (topic, payload, kwargs)
|
||||
|
||||
|
||||
class BridgeTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.options = self.root / UUID / 'options'
|
||||
self.options.mkdir(parents=True)
|
||||
for option in OPTIONS:
|
||||
(self.options / option).write_text('1\n')
|
||||
self.client = Client()
|
||||
self.bridge = Bridge(self.client, UUID, self.root)
|
||||
|
||||
def command(self, payload, retain=False, option='reconcile_enabled'):
|
||||
self.bridge.on_message(self.client, None, SimpleNamespace(
|
||||
topic=f'{self.bridge.base}/{option}/set', payload=payload, retain=retain))
|
||||
|
||||
def test_connect_discovery_and_state_before_online(self):
|
||||
self.bridge.on_connect(self.client, None, None, SimpleNamespace(is_failure=False), None)
|
||||
configs = [json.loads(value) for topic, value in self.client.messages if topic.endswith('/config')]
|
||||
self.assertEqual(len(configs), 2)
|
||||
self.assertFalse(configs[0]['optimistic'])
|
||||
self.assertFalse(configs[0]['retain'])
|
||||
self.assertEqual(configs[0]['availability_mode'], 'all')
|
||||
self.assertEqual(self.client.messages[-1], (f'{self.bridge.base}/availability', 'online'))
|
||||
self.assertEqual(self.client.will[1], 'offline')
|
||||
self.assertTrue(self.client.will[2]['retain'])
|
||||
|
||||
def test_cli_changes_and_unmount_remount(self):
|
||||
self.bridge.sample()
|
||||
path = self.options / 'reconcile_enabled'
|
||||
path.write_text('0\n')
|
||||
self.bridge.sample()
|
||||
self.assertIn((f'{self.bridge.base}/reconcile_enabled/state', '0'), self.client.messages)
|
||||
path.unlink()
|
||||
self.bridge.sample()
|
||||
self.assertEqual(self.client.messages[-1], (f'{self.bridge.base}/reconcile_enabled/availability', 'offline'))
|
||||
self.command(b'1')
|
||||
self.assertFalse(path.exists())
|
||||
path.write_text('1\n')
|
||||
self.bridge.sample()
|
||||
self.assertEqual(self.client.messages[-1], (f'{self.bridge.base}/reconcile_enabled/availability', 'online'))
|
||||
|
||||
def test_commands_and_rejected_retained_payload(self):
|
||||
self.command(b'0')
|
||||
self.assertEqual((self.options / 'reconcile_enabled').read_text(), '0\n')
|
||||
self.command(b'1', retain=True)
|
||||
self.command(b'ON')
|
||||
self.command(b'1', option='unknown')
|
||||
self.assertEqual((self.options / 'reconcile_enabled').read_text(), '0\n')
|
||||
self.command(b'1')
|
||||
self.assertEqual((self.options / 'reconcile_enabled').read_text(), '1\n')
|
||||
|
||||
|
||||
class ReportTest(unittest.TestCase):
|
||||
def test_outputs_failures_timeouts_and_html_escaping(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
report = Report(directory, timeout=.1)
|
||||
report.run('dmesg', [sys.executable, '-c', 'import sys; print("<script>oops</script>"); print("stderr",file=sys.stderr); sys.exit(3)'])
|
||||
report.run('missing', ['/no/such/command'])
|
||||
report.run('timeout', [sys.executable, '-c', 'import time; time.sleep(10)'])
|
||||
report.render()
|
||||
root = Path(directory)
|
||||
self.assertEqual([r['status'] for r in report.records], [3, 127, 124])
|
||||
self.assertIn('stderr', (root / 'outputs/001-dmesg.txt').read_text())
|
||||
page = (root / 'index.html').read_text()
|
||||
self.assertIn('<script>', page)
|
||||
self.assertNotIn('<script>oops</script>', page)
|
||||
self.assertIn('output.textContent = await response.text()', page)
|
||||
self.assertIn('outputs/001-dmesg.txt', page)
|
||||
self.assertIn('stderr', (root / 'kent_report.log').read_text())
|
||||
self.assertIn('001-dmesg.txt', (root / 'outputs/index.html').read_text())
|
||||
self.assertEqual(len(json.loads((root / 'manifest.json').read_text())['commands']), 3)
|
||||
|
||||
|
||||
class UploadTest(unittest.TestCase):
|
||||
def test_directory_upload_artifacts_and_failures(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
report = Report(root / 'report')
|
||||
report.run('example', ['printf', 'example'])
|
||||
report.render()
|
||||
(report.directory / 'trace.dat').write_text('must remain local')
|
||||
stub = root / 'kubectl'
|
||||
stub.write_text('''#!/bin/bash
|
||||
case $1 in
|
||||
get) echo brunner-ninja-test ;;
|
||||
cp)
|
||||
[[ -f $2/index.html && -f $2/outputs/001-example.txt && ! -e $2/trace.dat ]] || exit 9
|
||||
exit "${MOCK_CP_STATUS:-0}" ;;
|
||||
exec) exit "${MOCK_RENAME_STATUS:-0}" ;;
|
||||
esac
|
||||
''')
|
||||
stub.chmod(0o755)
|
||||
env = dict(os.environ, PATH=str(root) + ':' + os.environ['PATH'], TMPDIR=str(root))
|
||||
command = ['bash', '-c', 'source "$1"; paste_directory "$2"', 'bash', str(ROOT / 'paste_log.sh'), str(report.directory)]
|
||||
result = subprocess.run(command, env=env, capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertTrue(result.stdout.startswith('https://brunner.ninja/pastes/kent-report-'))
|
||||
result = subprocess.run(command, env=env | {'MOCK_CP_STATUS': '7'}, capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 7)
|
||||
self.assertEqual(result.stdout, '')
|
||||
result = subprocess.run(command, env=env | {'MOCK_RENAME_STATUS': '8'}, capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 8)
|
||||
self.assertEqual(result.stdout, '')
|
||||
self.assertEqual(list(root.glob('kent-report-*')), [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user