diff --git a/README.md b/README.md index b00a396..f585672 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/kent_report.py b/kent_report.py new file mode 100644 index 0000000..9e229c1 --- /dev/null +++ b/kent_report.py @@ -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'''
{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() diff --git a/kent_report.sh b/kent_report.sh index cfa7dc0..e735ac2 100755 --- a/kent_report.sh +++ b/kent_report.sh @@ -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 diff --git a/paste_log.sh b/paste_log.sh index f2e48b5..6d9e66b 100755 --- a/paste_log.sh +++ b/paste_log.sh @@ -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 ' >&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 diff --git a/tests/test_mqtt_report.py b/tests/test_mqtt_report.py new file mode 100644 index 0000000..6cfa56d --- /dev/null +++ b/tests/test_mqtt_report.py @@ -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(""); 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('', 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()