Publish browsable support reports with separate command outputs

This commit is contained in:
feedc0de committed 2026-11-01 20:15:55 +01:00
1 parent 88f76a6874
commit c27f64dc3d
5 files changed
+491 -137

No files matched your search

+93 -14
View File
@@ -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
View File
@@ -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
View File
@@ -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
+35
View File
@@ -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
+141
View File
@@ -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('&lt;script&gt;', 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()