1
0
mirror of https://github.com/home-assistant/supervisor.git synced 2025-12-20 18:38:59 +00:00
Files
supervisor/tests/host/test_logs.py
Jan Čermák 9f3767b23d Return cursor of the first host logs entry via headers (#5333)
* Return cursor of the first host logs entry via headers

Return first entry's cursor via custom `X-First-Cursor` header that can
be consumed by the client and used for continual requesting of the
historic logs. Once the first fetch returns data, the cursor can be
supplied as the first argument to the Range header in another call,
fetching accurate slice of the journal with the previous log entries
using the `Range: entries=cursor[[:num_skip]:num_entries]` syntax.

Let's say we fetch logs with the Range header `entries=:-19:20` (to
fetch very last 20 lines of the logs, see below why not
`entries:-20:20`) and we get `cursor50` as the reply (the actual value
will be much more complex and with no guaranteed format). To fetch
previous slice of the logs, we use `entries=cursor50:-20:20`, which
would return 20 lines previous to `cursor50` and `cursor30` in the
cursor header. This way we can go all the way back to the history.

One problem with the cursor is that it's not possible to determine when
the negative num_skip points beyond the first log entry. In that case
the client either needs to know what the first entry is (via
`entries=:0:1`) or can iterate naively and stop once two subsequent
requests return the same first cursor.

Another caveat, even though it's unlikely it will be hit in real usage,
is that it's not possible to fetch the last line only - if no cursor is
provided, negative num_skip argument is needed, and in that case we're
pointing one record back from the current cursor, which is the previous
record. The least we can return without knowing any cursor is thus
`entries=:-1:2` (where the `2` can be omitted, however with
`entries=:-1:1` we would lose the last line). This also explains why
different `num_skip` and `num_entries` must be used for the first fetch.

* Fix typo (fallback->callback)

* Refactor journal_logs_reader to always return the cursor

* Update tests for new cursor handling
2024-10-09 20:27:29 +02:00

142 lines
5.0 KiB
Python

"""Test host logs control."""
from unittest.mock import MagicMock, PropertyMock, patch
from aiohttp.client_exceptions import UnixClientConnectorError
from aiohttp.client_reqrep import ConnectionKey
import pytest
from supervisor.coresys import CoreSys
from supervisor.exceptions import HostNotSupportedError, HostServiceError
from supervisor.host.const import LogFormatter
from supervisor.host.logs import LogsControl
from supervisor.utils.systemd_journal import journal_logs_reader
from tests.common import load_fixture
TEST_BOOT_IDS = [
"b2aca10d5ca54fb1b6fb35c85a0efca9",
"b1c386a144fd44db8f855d7e907256f8",
]
async def test_load(coresys: CoreSys):
"""Test load."""
assert coresys.host.logs.default_identifiers == []
await coresys.host.logs.load()
assert coresys.host.logs.boot_ids == []
# File is quite large so just check it loaded
for identifier in ["kernel", "os-agent", "systemd"]:
assert identifier in coresys.host.logs.default_identifiers
async def test_logs(coresys: CoreSys, journald_gateway: MagicMock):
"""Test getting logs and errors."""
assert coresys.host.logs.available is True
journald_gateway.feed_data(load_fixture("logs_export_host.txt").encode("utf-8"))
journald_gateway.feed_eof()
async with coresys.host.logs.journald_logs() as resp:
cursor, line = await anext(
journal_logs_reader(resp, log_formatter=LogFormatter.VERBOSE)
)
assert (
cursor
== "s=83fee99ca0c3466db5fc120d52ca7dd8;i=203f2ce;b=f5a5c442fa6548cf97474d2d57c920b3;m=3191a3c620;t=612ccd299e7af;x=8675b540119d10bb"
)
assert (
line
== "2024-03-04 02:52:56.193 homeassistant systemd[1]: Started Hostname Service."
)
with (
patch.object(LogsControl, "available", new=PropertyMock(return_value=False)),
pytest.raises(HostNotSupportedError),
):
async with coresys.host.logs.journald_logs():
pass
async def test_logs_coloured(coresys: CoreSys, journald_gateway: MagicMock):
"""Test ANSI control sequences being preserved in binary messages."""
journald_gateway.feed_data(
load_fixture("logs_export_supervisor.txt").encode("utf-8")
)
journald_gateway.feed_eof()
async with coresys.host.logs.journald_logs() as resp:
cursor, line = await anext(journal_logs_reader(resp))
assert (
cursor
== "s=83fee99ca0c3466db5fc120d52ca7dd8;i=2049389;b=f5a5c442fa6548cf97474d2d57c920b3;m=4263828e8c;t=612dda478b01b;x=9ae12394c9326930"
)
assert (
line
== "\x1b[32m24-03-04 23:56:56 INFO (MainThread) [__main__] Closing Supervisor\x1b[0m"
)
async def test_boot_ids(coresys: CoreSys, journald_gateway: MagicMock):
"""Test getting boot ids."""
journald_gateway.feed_data(load_fixture("logs_boot_ids.txt").encode("utf-8"))
journald_gateway.feed_eof()
assert await coresys.host.logs.get_boot_ids() == TEST_BOOT_IDS
# Boot ID query should not be run again, mock a failure for it to ensure
journald_gateway.side_effect = TimeoutError()
assert await coresys.host.logs.get_boot_ids() == TEST_BOOT_IDS
assert await coresys.host.logs.get_boot_id(0) == "b1c386a144fd44db8f855d7e907256f8"
# -1 is previous boot. We have 2 boots so -2 is too far
assert await coresys.host.logs.get_boot_id(-1) == "b2aca10d5ca54fb1b6fb35c85a0efca9"
with pytest.raises(ValueError):
await coresys.host.logs.get_boot_id(-2)
# 1 is oldest boot and count up from there. We have 2 boots so 3 is too far
assert await coresys.host.logs.get_boot_id(1) == "b2aca10d5ca54fb1b6fb35c85a0efca9"
assert await coresys.host.logs.get_boot_id(2) == "b1c386a144fd44db8f855d7e907256f8"
with pytest.raises(ValueError):
await coresys.host.logs.get_boot_id(3)
async def test_identifiers(coresys: CoreSys, journald_gateway: MagicMock):
"""Test getting identifiers."""
journald_gateway.feed_data(load_fixture("logs_identifiers.txt").encode("utf-8"))
journald_gateway.feed_eof()
# Mock is large so just look for a few different types of identifiers
identifiers = await coresys.host.logs.get_identifiers()
for identifier in [
"addon_local_ssh",
"hassio_dns",
"hassio_supervisor",
"kernel",
"os-agent",
]:
assert identifier in identifiers
assert "" not in identifiers
async def test_connection_refused_handled(
coresys: CoreSys, journald_gateway: MagicMock
):
"""Test connection refused is handled with HostServiceError."""
with patch("supervisor.host.logs.ClientSession.get") as get:
get.side_effect = UnixClientConnectorError(
path="/run/systemd-journal-gatewayd.sock",
connection_key=ConnectionKey(
"localhost", None, False, False, None, None, None
),
os_error=ConnectionRefusedError("Connection refused"),
)
with pytest.raises(HostServiceError):
async with coresys.host.logs.journald_logs():
pass