Skip to content

Commit

Permalink
Add fallback for boot IDs query (#5391)
Browse files Browse the repository at this point in the history
  • Loading branch information
mdegat01 authored Nov 5, 2024
1 parent ac5ce4c commit 55e58d3
Show file tree
Hide file tree
Showing 4 changed files with 71 additions and 24 deletions.
30 changes: 23 additions & 7 deletions supervisor/host/logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
import json
import logging
Expand Down Expand Up @@ -101,18 +102,33 @@ async def get_boot_ids(self) -> list[str]:
timeout=ClientTimeout(total=20),
) as resp:
text = await resp.text()
self._boot_ids = [
json.loads(entry)[PARAM_BOOT_ID]
for entry in text.split("\n")
if entry
]
return self._boot_ids
except (ClientError, TimeoutError) as err:
raise HostLogError(
"Could not get a list of boot IDs from systemd-journal-gatewayd",
_LOGGER.error,
) from err

# If a system has not been rebooted in a long time query can come back with zero results
# Fallback is to get latest log line and its boot ID so we always have at least one.
if not text:
try:
async with self.journald_logs(
range_header="entries=:-1:1",
accept=LogFormat.JSON,
timeout=ClientTimeout(total=20),
) as resp:
text = await resp.text()
except (ClientError, TimeoutError) as err:
raise HostLogError(
"Could not get a list of boot IDs from systemd-journal-gatewayd",
_LOGGER.error,
) from err

self._boot_ids = [
json.loads(entry)[PARAM_BOOT_ID] for entry in text.split("\n") if entry
]
return self._boot_ids

async def get_identifiers(self) -> list[str]:
"""Get syslog identifiers."""
try:
Expand All @@ -135,7 +151,7 @@ async def journald_logs(
range_header: str | None = None,
accept: LogFormat = LogFormat.TEXT,
timeout: ClientTimeout | None = None,
) -> ClientResponse:
) -> AsyncGenerator[ClientResponse]:
"""Get logs from systemd-journal-gatewayd.
See https://www.freedesktop.org/software/systemd/man/systemd-journal-gatewayd.service.html for params and more info.
Expand Down
3 changes: 2 additions & 1 deletion tests/api/test_host.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Test Host API."""

from collections.abc import AsyncGenerator
from unittest.mock import ANY, MagicMock, patch

from aiohttp.test_utils import TestClient
Expand All @@ -20,7 +21,7 @@


@pytest.fixture(name="coresys_disk_info")
async def fixture_coresys_disk_info(coresys: CoreSys) -> CoreSys:
async def fixture_coresys_disk_info(coresys: CoreSys) -> AsyncGenerator[CoreSys]:
"""Mock basic disk information for host APIs."""
coresys.hardware.disk.get_disk_life_time = lambda _: 0
coresys.hardware.disk.get_disk_free_space = lambda _: 5000
Expand Down
12 changes: 5 additions & 7 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -422,27 +422,25 @@ async def tmp_supervisor_data(coresys: CoreSys, tmp_path: Path) -> Path:


@pytest.fixture
async def journald_gateway() -> MagicMock:
async def journald_gateway() -> AsyncGenerator[MagicMock]:
"""Mock logs control."""
with (
patch("supervisor.host.logs.Path.is_socket", return_value=True),
patch("supervisor.host.logs.ClientSession.get") as get,
):
reader = asyncio.StreamReader(loop=asyncio.get_running_loop())
client_response = MagicMock(content=reader, get=get)

async def response_text():
return (await reader.read()).decode("utf-8")
return (await client_response.content.read()).decode("utf-8")

client_response = MagicMock(
content=reader,
text=response_text,
)
client_response.text = response_text

get.return_value.__aenter__.return_value = client_response
get.return_value.__aenter__.return_value.__aenter__.return_value = (
client_response
)
yield reader
yield client_response


@pytest.fixture
Expand Down
50 changes: 41 additions & 9 deletions tests/host/test_logs.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Test host logs control."""

import asyncio
from unittest.mock import MagicMock, PropertyMock, patch

from aiohttp.client_exceptions import UnixClientConnectorError
Expand Down Expand Up @@ -36,8 +37,10 @@ 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()
journald_gateway.content.feed_data(
load_fixture("logs_export_host.txt").encode("utf-8")
)
journald_gateway.content.feed_eof()

async with coresys.host.logs.journald_logs() as resp:
cursor, line = await anext(
Expand All @@ -62,10 +65,10 @@ async def test_logs(coresys: CoreSys, journald_gateway: MagicMock):

async def test_logs_coloured(coresys: CoreSys, journald_gateway: MagicMock):
"""Test ANSI control sequences being preserved in binary messages."""
journald_gateway.feed_data(
journald_gateway.content.feed_data(
load_fixture("logs_export_supervisor.txt").encode("utf-8")
)
journald_gateway.feed_eof()
journald_gateway.content.feed_eof()

async with coresys.host.logs.journald_logs() as resp:
cursor, line = await anext(journal_logs_reader(resp))
Expand All @@ -81,13 +84,15 @@ async def test_logs_coloured(coresys: CoreSys, journald_gateway: MagicMock):

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()
journald_gateway.content.feed_data(
load_fixture("logs_boot_ids.txt").encode("utf-8")
)
journald_gateway.content.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()
journald_gateway.get.side_effect = TimeoutError()
assert await coresys.host.logs.get_boot_ids() == TEST_BOOT_IDS

assert await coresys.host.logs.get_boot_id(0) == "b1c386a144fd44db8f855d7e907256f8"
Expand All @@ -104,10 +109,37 @@ async def test_boot_ids(coresys: CoreSys, journald_gateway: MagicMock):
await coresys.host.logs.get_boot_id(3)


async def test_boot_ids_fallback(coresys: CoreSys, journald_gateway: MagicMock):
"""Test getting boot ids using fallback."""
# Initial response has no log lines
journald_gateway.content.feed_data(b"")
journald_gateway.content.feed_eof()

# Fallback contains exactly one with a boot ID
boot_id_data = load_fixture("logs_boot_ids.txt")
reader = asyncio.StreamReader(loop=asyncio.get_running_loop())
reader.feed_data(boot_id_data.split("\n")[0].encode("utf-8"))
reader.feed_eof()

readers = [journald_gateway.content, reader]

def get_side_effect(*args, **kwargs):
journald_gateway.content = readers.pop(0)
return journald_gateway.get.return_value

journald_gateway.get.side_effect = get_side_effect

assert await coresys.host.logs.get_boot_ids() == [
"b2aca10d5ca54fb1b6fb35c85a0efca9"
]


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()
journald_gateway.content.feed_data(
load_fixture("logs_identifiers.txt").encode("utf-8")
)
journald_gateway.content.feed_eof()

# Mock is large so just look for a few different types of identifiers
identifiers = await coresys.host.logs.get_identifiers()
Expand Down

0 comments on commit 55e58d3

Please sign in to comment.