mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
Without `--all`, `journalctl --output=json` encodes a field over 4096 bytes as `null`, so a long host error matched nothing and the check passed. A repeated `MESSAGE` field arrives as a JSON array of its values, and `bytes()` on that array raised `TypeError`, which aborted the whole check. Pass `--all`, and tell a byte array (all integers) from an array of field values, decoding each value on its own. Reproduced against the host journal: a 5000-byte error line read back as `"MESSAGE":null` and produced no match; an entry with two `MESSAGE` fields, sent over the native journal socket, read back as `["error first","error second"]` and crashed `host_errors` with `TypeError`. With the fix both are reported. Also clear the two test-lint findings of this PR: `test_comments_valid` rejects the narrative comment above the `journalctl` Popen, so it goes; the stderr-to-file choice stays, because an unread stderr pipe that fills up would block `journalctl` while stdout is still being read. `test_no_uncached_file_reads` flags the read of the stub's argument file, which lives in the test's tempdir and is written per run, so the path-keyed `utils.cache.files.read_text` could serve a stale body; the read opts out with `# nocheck: cache-read tempdir fixture`, as the other stub-driven script tests do. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
87 lines
2.4 KiB
Python
Executable File
87 lines
2.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
JOURNALCTL = [
|
|
"journalctl",
|
|
"--since",
|
|
"1 day ago",
|
|
"--no-pager",
|
|
"--all",
|
|
"--output=json",
|
|
"--output-fields=MESSAGE,CONTAINER_ID,SYSLOG_IDENTIFIER,_SYSTEMD_UNIT",
|
|
]
|
|
|
|
|
|
def message_text(value):
|
|
"""Return a journal MESSAGE as one line of text.
|
|
|
|
Args:
|
|
value: the MESSAGE field; journald encodes non-UTF-8 messages as a list of bytes and a repeated field as a list of its values.
|
|
|
|
Returns:
|
|
The message with line breaks collapsed to spaces.
|
|
"""
|
|
if isinstance(value, list) and all(isinstance(item, int) for item in value):
|
|
value = bytes(value).decode("utf-8", errors="replace")
|
|
elif isinstance(value, list):
|
|
value = "\n".join(message_text(item) for item in value)
|
|
return " ".join(str(value).splitlines())
|
|
|
|
|
|
def host_errors(lines):
|
|
"""Yield `source: message` for every non-container entry that mentions an error.
|
|
|
|
Args:
|
|
lines: iterable of journalctl JSON lines, one entry per line, consumed lazily.
|
|
|
|
Returns:
|
|
Iterator of readable error lines.
|
|
"""
|
|
for line in lines:
|
|
if not line.strip():
|
|
continue
|
|
entry = json.loads(line)
|
|
if "CONTAINER_ID" in entry:
|
|
continue
|
|
message = message_text(entry.get("MESSAGE", ""))
|
|
if "error" not in message.lower():
|
|
continue
|
|
source = (
|
|
entry.get("_SYSTEMD_UNIT") or entry.get("SYSLOG_IDENTIFIER") or "unknown"
|
|
)
|
|
yield f"{source}: {message}"
|
|
|
|
|
|
def main():
|
|
print("Checking journalctl for error messages outside container logs...")
|
|
with tempfile.TemporaryFile(
|
|
mode="w+", encoding="utf-8", errors="replace"
|
|
) as stderr:
|
|
with subprocess.Popen(
|
|
JOURNALCTL,
|
|
stdout=subprocess.PIPE,
|
|
stderr=stderr,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
) as journal:
|
|
errors = list(host_errors(journal.stdout))
|
|
if journal.returncode != 0:
|
|
stderr.seek(0)
|
|
print(
|
|
f"journalctl failed with exit code {journal.returncode}: {stderr.read().strip()}"
|
|
)
|
|
return 2
|
|
if errors:
|
|
print("Some errors were found:")
|
|
print("\n".join(errors))
|
|
return 1
|
|
print("No errors found in the journal.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|