diff --git a/supervisor/host/logs.py b/supervisor/host/logs.py index f616749d508..38c51d90ef7 100644 --- a/supervisor/host/logs.py +++ b/supervisor/host/logs.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import AsyncGenerator from contextlib import asynccontextmanager import json import logging @@ -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: @@ -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. diff --git a/tests/api/test_host.py b/tests/api/test_host.py index 92064373122..385c0309555 100644 --- a/tests/api/test_host.py +++ b/tests/api/test_host.py @@ -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 @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index c9fbe91c642..7ffeacdc95c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/host/test_logs.py b/tests/host/test_logs.py index 86c7bf49b4d..14e788e8172 100644 --- a/tests/host/test_logs.py +++ b/tests/host/test_logs.py @@ -1,5 +1,6 @@ """Test host logs control.""" +import asyncio from unittest.mock import MagicMock, PropertyMock, patch from aiohttp.client_exceptions import UnixClientConnectorError @@ -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( @@ -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)) @@ -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" @@ -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()