Skip to content

Commit 2e22e1e

Browse files
authored
Add endpoint for complete logs of the latest container startup (#6163)
* Add endpoint for complete logs of the latest container startup Add endpoint that returns complete logs of the latest startup of container, which can be used for downloading Core logs in the frontend. Realtime filtering header is used for the Journal API and StartedAt parameter from the Docker API is used as the reference point. This means that any other Range header is ignored for this parameter, yet the "lines" query argument can be used to limit the number of lines. By default "infinite" number of lines is returned. Closes #6147 * Implement fallback for latest logs for OS older than 16.0 Implement fallback which uses the internal CONTAINER_LOG_EPOCH metadata added to logs created by the Docker logger. Still prefer the time-based method, as it has lower overhead and using public APIs. * Address review comments * Only use CONTAINER_LOG_EPOCH for latest logs As pointed out in the review comments, we might not be able to get the StartedAt for add-ons that are not running. Thus we need to use the only reliable mechanism available now, which is the container log epoch. * Remove dead code for 'Range: realtime' header handling
1 parent e7f3573 commit 2e22e1e

File tree

11 files changed

+175
-19
lines changed

11 files changed

+175
-19
lines changed

supervisor/api/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,14 @@ def _register_advanced_logs(self, path: str, syslog_identifier: str):
146146
follow=True,
147147
),
148148
),
149+
web.get(
150+
f"{path}/logs/latest",
151+
partial(
152+
self._api_host.advanced_logs,
153+
identifier=syslog_identifier,
154+
latest=True,
155+
),
156+
),
149157
web.get(
150158
f"{path}/logs/boots/{{bootid}}",
151159
partial(self._api_host.advanced_logs, identifier=syslog_identifier),
@@ -440,6 +448,7 @@ async def get_supervisor_logs(*args, **kwargs):
440448
# is known and reported to the user using the resolution center.
441449
await async_capture_exception(err)
442450
kwargs.pop("follow", None) # Follow is not supported for Docker logs
451+
kwargs.pop("latest", None) # Latest is not supported for Docker logs
443452
return await api_supervisor.logs(*args, **kwargs)
444453

445454
self.webapp.add_routes(
@@ -449,6 +458,10 @@ async def get_supervisor_logs(*args, **kwargs):
449458
"/supervisor/logs/follow",
450459
partial(get_supervisor_logs, follow=True),
451460
),
461+
web.get(
462+
"/supervisor/logs/latest",
463+
partial(get_supervisor_logs, latest=True),
464+
),
452465
web.get("/supervisor/logs/boots/{bootid}", get_supervisor_logs),
453466
web.get(
454467
"/supervisor/logs/boots/{bootid}/follow",
@@ -561,6 +574,10 @@ async def get_addon_logs(request, *args, **kwargs):
561574
"/addons/{addon}/logs/follow",
562575
partial(get_addon_logs, follow=True),
563576
),
577+
web.get(
578+
"/addons/{addon}/logs/latest",
579+
partial(get_addon_logs, latest=True),
580+
),
564581
web.get("/addons/{addon}/logs/boots/{bootid}", get_addon_logs),
565582
web.get(
566583
"/addons/{addon}/logs/boots/{bootid}/follow",

supervisor/api/host.py

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,17 @@
22

33
import asyncio
44
from contextlib import suppress
5+
import json
56
import logging
67
from typing import Any
78

8-
from aiohttp import ClientConnectionResetError, ClientPayloadError, web
9+
from aiohttp import (
10+
ClientConnectionResetError,
11+
ClientError,
12+
ClientPayloadError,
13+
ClientTimeout,
14+
web,
15+
)
916
from aiohttp.hdrs import ACCEPT, RANGE
1017
import voluptuous as vol
1118
from voluptuous.error import CoerceInvalid
@@ -194,7 +201,11 @@ async def _get_boot_id(self, possible_offset: str) -> str:
194201
return possible_offset
195202

196203
async def advanced_logs_handler(
197-
self, request: web.Request, identifier: str | None = None, follow: bool = False
204+
self,
205+
request: web.Request,
206+
identifier: str | None = None,
207+
follow: bool = False,
208+
latest: bool = False,
198209
) -> web.StreamResponse:
199210
"""Return systemd-journald logs."""
200211
log_formatter = LogFormatter.PLAIN
@@ -213,6 +224,20 @@ async def advanced_logs_handler(
213224
if follow:
214225
params[PARAM_FOLLOW] = ""
215226

227+
if latest:
228+
if not identifier:
229+
raise APIError(
230+
"Latest logs can only be fetched for a specific identifier."
231+
)
232+
233+
try:
234+
epoch = await self._get_container_last_epoch(identifier)
235+
params["CONTAINER_LOG_EPOCH"] = epoch
236+
except HostLogError as err:
237+
raise APIError(
238+
f"Cannot determine CONTAINER_LOG_EPOCH of {identifier}, latest logs not available."
239+
) from err
240+
216241
if ACCEPT in request.headers and request.headers[ACCEPT] not in [
217242
CONTENT_TYPE_TEXT,
218243
CONTENT_TYPE_X_LOG,
@@ -241,6 +266,8 @@ async def advanced_logs_handler(
241266
lines = max(2, lines)
242267
# entries=cursor[[:num_skip]:num_entries]
243268
range_header = f"entries=:-{lines - 1}:{SYSTEMD_JOURNAL_GATEWAYD_LINES_MAX if follow else lines}"
269+
elif latest:
270+
range_header = f"entries=0:{SYSTEMD_JOURNAL_GATEWAYD_LINES_MAX}"
244271
elif RANGE in request.headers:
245272
range_header = request.headers[RANGE]
246273
else:
@@ -286,10 +313,14 @@ async def advanced_logs_handler(
286313

287314
@api_process_raw(CONTENT_TYPE_TEXT, error_type=CONTENT_TYPE_TEXT)
288315
async def advanced_logs(
289-
self, request: web.Request, identifier: str | None = None, follow: bool = False
316+
self,
317+
request: web.Request,
318+
identifier: str | None = None,
319+
follow: bool = False,
320+
latest: bool = False,
290321
) -> web.StreamResponse:
291322
"""Return systemd-journald logs. Wrapped as standard API handler."""
292-
return await self.advanced_logs_handler(request, identifier, follow)
323+
return await self.advanced_logs_handler(request, identifier, follow, latest)
293324

294325
@api_process
295326
async def disk_usage(self, request: web.Request) -> dict:
@@ -336,3 +367,27 @@ async def disk_usage(self, request: web.Request) -> dict:
336367
*known_paths,
337368
],
338369
}
370+
371+
async def _get_container_last_epoch(self, identifier: str) -> str | None:
372+
"""Get Docker's internal log epoch of the latest log entry for the given identifier."""
373+
try:
374+
async with self.sys_host.logs.journald_logs(
375+
params={"CONTAINER_NAME": identifier},
376+
range_header="entries=:-1:2", # -1 = next to the last entry
377+
accept=LogFormat.JSON,
378+
timeout=ClientTimeout(total=10),
379+
) as resp:
380+
text = await resp.text()
381+
except (ClientError, TimeoutError) as err:
382+
raise HostLogError(
383+
"Could not get last container epoch from systemd-journal-gatewayd",
384+
_LOGGER.error,
385+
) from err
386+
387+
try:
388+
return json.loads(text.strip().split("\n")[-1])["CONTAINER_LOG_EPOCH"]
389+
except (json.JSONDecodeError, KeyError, IndexError) as err:
390+
raise HostLogError(
391+
f"Failed to parse CONTAINER_LOG_EPOCH of {identifier} container, got: {text}",
392+
_LOGGER.error,
393+
) from err

tests/api/__init__.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
"""Test for API calls."""
22

3-
from unittest.mock import MagicMock
3+
from unittest.mock import AsyncMock, MagicMock
44

55
from aiohttp.test_utils import TestClient
66

7+
from supervisor.coresys import CoreSys
78
from supervisor.host.const import LogFormat
89

910
DEFAULT_LOG_RANGE = "entries=:-99:100"
@@ -15,6 +16,8 @@ async def common_test_api_advanced_logs(
1516
syslog_identifier: str,
1617
api_client: TestClient,
1718
journald_logs: MagicMock,
19+
coresys: CoreSys,
20+
os_available: None,
1821
):
1922
"""Template for tests of endpoints using advanced logs."""
2023
resp = await api_client.get(f"{path_prefix}/logs")
@@ -41,6 +44,30 @@ async def common_test_api_advanced_logs(
4144

4245
journald_logs.reset_mock()
4346

47+
mock_response = MagicMock()
48+
mock_response.text = AsyncMock(
49+
return_value='{"CONTAINER_LOG_EPOCH": "12345"}\n{"CONTAINER_LOG_EPOCH": "12345"}\n'
50+
)
51+
journald_logs.return_value.__aenter__.return_value = mock_response
52+
53+
resp = await api_client.get(f"{path_prefix}/logs/latest")
54+
assert resp.status == 200
55+
56+
assert journald_logs.call_count == 2
57+
58+
# Check the first call for getting epoch
59+
epoch_call = journald_logs.call_args_list[0]
60+
assert epoch_call[1]["params"] == {"CONTAINER_NAME": syslog_identifier}
61+
assert epoch_call[1]["range_header"] == "entries=:-1:2"
62+
63+
# Check the second call for getting logs with the epoch
64+
logs_call = journald_logs.call_args_list[1]
65+
assert logs_call[1]["params"]["SYSLOG_IDENTIFIER"] == syslog_identifier
66+
assert logs_call[1]["params"]["CONTAINER_LOG_EPOCH"] == "12345"
67+
assert logs_call[1]["range_header"] == "entries=0:18446744073709551615"
68+
69+
journald_logs.reset_mock()
70+
4471
resp = await api_client.get(f"{path_prefix}/logs/boots/0")
4572
assert resp.status == 200
4673
assert resp.content_type == "text/plain"

tests/api/test_addons.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,20 @@ async def test_addons_info_not_installed(
7272

7373

7474
async def test_api_addon_logs(
75-
api_client: TestClient, journald_logs: MagicMock, install_addon_ssh: Addon
75+
api_client: TestClient,
76+
journald_logs: MagicMock,
77+
coresys: CoreSys,
78+
os_available,
79+
install_addon_ssh: Addon,
7680
):
7781
"""Test addon logs."""
7882
await common_test_api_advanced_logs(
79-
"/addons/local_ssh", "addon_local_ssh", api_client, journald_logs
83+
"/addons/local_ssh",
84+
"addon_local_ssh",
85+
api_client,
86+
journald_logs,
87+
coresys,
88+
os_available,
8089
)
8190

8291

tests/api/test_audio.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@
44

55
from aiohttp.test_utils import TestClient
66

7+
from supervisor.coresys import CoreSys
8+
79
from tests.api import common_test_api_advanced_logs
810

911

10-
async def test_api_audio_logs(api_client: TestClient, journald_logs: MagicMock):
12+
async def test_api_audio_logs(
13+
api_client: TestClient, journald_logs: MagicMock, coresys: CoreSys, os_available
14+
):
1115
"""Test audio logs."""
1216
await common_test_api_advanced_logs(
13-
"/audio", "hassio_audio", api_client, journald_logs
17+
"/audio", "hassio_audio", api_client, journald_logs, coresys, os_available
1418
)

tests/api/test_dns.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ async def test_options(api_client: TestClient, coresys: CoreSys):
6666
restart.assert_called_once()
6767

6868

69-
async def test_api_dns_logs(api_client: TestClient, journald_logs: MagicMock):
69+
async def test_api_dns_logs(
70+
api_client: TestClient, journald_logs: MagicMock, coresys: CoreSys, os_available
71+
):
7072
"""Test dns logs."""
71-
await common_test_api_advanced_logs("/dns", "hassio_dns", api_client, journald_logs)
73+
await common_test_api_advanced_logs(
74+
"/dns",
75+
"hassio_dns",
76+
api_client,
77+
journald_logs,
78+
coresys,
79+
os_available,
80+
)

tests/api/test_homeassistant.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,20 @@
2121

2222
@pytest.mark.parametrize("legacy_route", [True, False])
2323
async def test_api_core_logs(
24-
api_client: TestClient, journald_logs: MagicMock, legacy_route: bool
24+
api_client: TestClient,
25+
journald_logs: MagicMock,
26+
coresys: CoreSys,
27+
os_available,
28+
legacy_route: bool,
2529
):
2630
"""Test core logs."""
2731
await common_test_api_advanced_logs(
2832
f"/{'homeassistant' if legacy_route else 'core'}",
2933
"homeassistant",
3034
api_client,
3135
journald_logs,
36+
coresys,
37+
os_available,
3238
)
3339

3440

tests/api/test_host.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,10 @@ async def test_advanced_logs(
243243
accept=LogFormat.JOURNAL,
244244
)
245245

246+
# Host logs don't have a /latest endpoint
247+
resp = await api_client.get("/host/logs/latest")
248+
assert resp.status == 404
249+
246250

247251
async def test_advaced_logs_query_parameters(
248252
api_client: TestClient,

tests/api/test_multicast.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,20 @@
44

55
from aiohttp.test_utils import TestClient
66

7+
from supervisor.coresys import CoreSys
8+
79
from tests.api import common_test_api_advanced_logs
810

911

10-
async def test_api_multicast_logs(api_client: TestClient, journald_logs: MagicMock):
12+
async def test_api_multicast_logs(
13+
api_client: TestClient, journald_logs: MagicMock, coresys: CoreSys, os_available
14+
):
1115
"""Test multicast logs."""
1216
await common_test_api_advanced_logs(
13-
"/multicast", "hassio_multicast", api_client, journald_logs
17+
"/multicast",
18+
"hassio_multicast",
19+
api_client,
20+
journald_logs,
21+
coresys,
22+
os_available,
1423
)

tests/api/test_supervisor.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,10 +148,17 @@ async def test_api_supervisor_options_diagnostics(
148148
assert coresys.dbus.agent.diagnostics is False
149149

150150

151-
async def test_api_supervisor_logs(api_client: TestClient, journald_logs: MagicMock):
151+
async def test_api_supervisor_logs(
152+
api_client: TestClient, journald_logs: MagicMock, coresys: CoreSys, os_available
153+
):
152154
"""Test supervisor logs."""
153155
await common_test_api_advanced_logs(
154-
"/supervisor", "hassio_supervisor", api_client, journald_logs
156+
"/supervisor",
157+
"hassio_supervisor",
158+
api_client,
159+
journald_logs,
160+
coresys,
161+
os_available,
155162
)
156163

157164

@@ -175,7 +182,7 @@ async def test_api_supervisor_fallback(
175182
b"\x1b[36m22-10-11 14:04:23 DEBUG (MainThread) [supervisor.utils.dbus] D-Bus call - org.freedesktop.DBus.Properties.call_get_all on /io/hass/os/AppArmor\x1b[0m",
176183
]
177184

178-
# check fallback also works for the follow endpoint (no mock reset needed)
185+
# check fallback also works for the /follow endpoint (no mock reset needed)
179186

180187
with patch("supervisor.api._LOGGER.exception") as logger:
181188
resp = await api_client.get("/supervisor/logs/follow")
@@ -186,7 +193,16 @@ async def test_api_supervisor_fallback(
186193
assert resp.status == 200
187194
assert resp.content_type == "text/plain"
188195

189-
journald_logs.reset_mock()
196+
# check the /latest endpoint as well
197+
198+
with patch("supervisor.api._LOGGER.exception") as logger:
199+
resp = await api_client.get("/supervisor/logs/latest")
200+
logger.assert_called_once_with(
201+
"Failed to get supervisor logs using advanced_logs API"
202+
)
203+
204+
assert resp.status == 200
205+
assert resp.content_type == "text/plain"
190206

191207
# also check generic Python error
192208
journald_logs.side_effect = OSError("Something bad happened!")

0 commit comments

Comments
 (0)