Files
kevinveenbirkenbachandClaude Opus 5.5 3855b75c5f fix(sys-ctl-hlth-journalctl): read long and repeated MESSAGE fields
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>
2026-10-16 22:11:09 +02:00

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())