Use Systemd Journal API for all logs endpoints in API (#4972)

* Use Systemd Journal API for all logs endpoints in API

Replace all logs endpoints using container logs with wrapped
advanced_logs function, adding possibility to get logs from previous
boots and following the logs. Supervisor logs are an excetion where
Docker logs are still used - in case an exception is raised while
accessing the Systemd logs, they're used as fallback - otherwise we
wouldn't have an easy way to see what went wrong.

* Refactor testing of advanced logs endpoints to a common method

* Send error while fetching Supervisor logs to Sentry; minor cleanup

* Properly handle errors and use consistent content type in logs endpoints

* Replace api_process_custom with reworked api_process_raw per @mdegat01 suggestion
This commit is contained in:
Jan Čermák 2024-04-04 12:09:08 +02:00 committed by GitHub
parent 56a8a1b5a1
commit a894c4589e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 304 additions and 137 deletions

View File

@ -10,11 +10,13 @@ from aiohttp_fast_url_dispatcher import FastUrlDispatcher, attach_fast_url_dispa
from ..const import AddonState from ..const import AddonState
from ..coresys import CoreSys, CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import APIAddonNotInstalled from ..exceptions import APIAddonNotInstalled
from ..utils.sentry import capture_exception
from .addons import APIAddons from .addons import APIAddons
from .audio import APIAudio from .audio import APIAudio
from .auth import APIAuth from .auth import APIAuth
from .backups import APIBackups from .backups import APIBackups
from .cli import APICli from .cli import APICli
from .const import CONTENT_TYPE_TEXT
from .discovery import APIDiscovery from .discovery import APIDiscovery
from .dns import APICoreDNS from .dns import APICoreDNS
from .docker import APIDocker from .docker import APIDocker
@ -36,7 +38,7 @@ from .security import APISecurity
from .services import APIServices from .services import APIServices
from .store import APIStore from .store import APIStore
from .supervisor import APISupervisor from .supervisor import APISupervisor
from .utils import api_process from .utils import api_process, api_process_raw
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@ -71,8 +73,14 @@ class RestAPI(CoreSysAttributes):
self._runner: web.AppRunner = web.AppRunner(self.webapp, shutdown_timeout=5) self._runner: web.AppRunner = web.AppRunner(self.webapp, shutdown_timeout=5)
self._site: web.TCPSite | None = None self._site: web.TCPSite | None = None
# share single host API handler for reuse in logging endpoints
self._api_host: APIHost | None = None
async def load(self) -> None: async def load(self) -> None:
"""Register REST API Calls.""" """Register REST API Calls."""
self._api_host = APIHost()
self._api_host.coresys = self.coresys
self._register_addons() self._register_addons()
self._register_audio() self._register_audio()
self._register_auth() self._register_auth()
@ -102,10 +110,41 @@ class RestAPI(CoreSysAttributes):
await self.start() await self.start()
def _register_advanced_logs(self, path: str, syslog_identifier: str):
"""Register logs endpoint for a given path, returning logs for single syslog identifier."""
self.webapp.add_routes(
[
web.get(
f"{path}/logs",
partial(self._api_host.advanced_logs, identifier=syslog_identifier),
),
web.get(
f"{path}/logs/follow",
partial(
self._api_host.advanced_logs,
identifier=syslog_identifier,
follow=True,
),
),
web.get(
f"{path}/logs/boots/{{bootid}}",
partial(self._api_host.advanced_logs, identifier=syslog_identifier),
),
web.get(
f"{path}/logs/boots/{{bootid}}/follow",
partial(
self._api_host.advanced_logs,
identifier=syslog_identifier,
follow=True,
),
),
]
)
def _register_host(self) -> None: def _register_host(self) -> None:
"""Register hostcontrol functions.""" """Register hostcontrol functions."""
api_host = APIHost() api_host = self._api_host
api_host.coresys = self.coresys
self.webapp.add_routes( self.webapp.add_routes(
[ [
@ -261,11 +300,11 @@ class RestAPI(CoreSysAttributes):
[ [
web.get("/multicast/info", api_multicast.info), web.get("/multicast/info", api_multicast.info),
web.get("/multicast/stats", api_multicast.stats), web.get("/multicast/stats", api_multicast.stats),
web.get("/multicast/logs", api_multicast.logs),
web.post("/multicast/update", api_multicast.update), web.post("/multicast/update", api_multicast.update),
web.post("/multicast/restart", api_multicast.restart), web.post("/multicast/restart", api_multicast.restart),
] ]
) )
self._register_advanced_logs("/multicast", "hassio_multicast")
def _register_hardware(self) -> None: def _register_hardware(self) -> None:
"""Register hardware functions.""" """Register hardware functions."""
@ -352,7 +391,6 @@ class RestAPI(CoreSysAttributes):
web.get("/supervisor/ping", api_supervisor.ping), web.get("/supervisor/ping", api_supervisor.ping),
web.get("/supervisor/info", api_supervisor.info), web.get("/supervisor/info", api_supervisor.info),
web.get("/supervisor/stats", api_supervisor.stats), web.get("/supervisor/stats", api_supervisor.stats),
web.get("/supervisor/logs", api_supervisor.logs),
web.post("/supervisor/update", api_supervisor.update), web.post("/supervisor/update", api_supervisor.update),
web.post("/supervisor/reload", api_supervisor.reload), web.post("/supervisor/reload", api_supervisor.reload),
web.post("/supervisor/restart", api_supervisor.restart), web.post("/supervisor/restart", api_supervisor.restart),
@ -361,6 +399,35 @@ class RestAPI(CoreSysAttributes):
] ]
) )
async def get_supervisor_logs(*args, **kwargs):
try:
return await self._api_host.advanced_logs(
*args, identifier="hassio_supervisor", **kwargs
)
except Exception as err: # pylint: disable=broad-exception-caught
# Supervisor logs are critical, so catch everything, log the exception
# and try to return Docker container logs as the fallback
_LOGGER.exception(
"Failed to get supervisor logs using advanced_logs API"
)
capture_exception(err)
return await api_supervisor.logs(*args, **kwargs)
self.webapp.add_routes(
[
web.get("/supervisor/logs", get_supervisor_logs),
web.get(
"/supervisor/logs/follow",
partial(get_supervisor_logs, follow=True),
),
web.get("/supervisor/logs/boots/{bootid}", get_supervisor_logs),
web.get(
"/supervisor/logs/boots/{bootid}/follow",
partial(get_supervisor_logs, follow=True),
),
]
)
def _register_homeassistant(self) -> None: def _register_homeassistant(self) -> None:
"""Register Home Assistant functions.""" """Register Home Assistant functions."""
api_hass = APIHomeAssistant() api_hass = APIHomeAssistant()
@ -369,7 +436,6 @@ class RestAPI(CoreSysAttributes):
self.webapp.add_routes( self.webapp.add_routes(
[ [
web.get("/core/info", api_hass.info), web.get("/core/info", api_hass.info),
web.get("/core/logs", api_hass.logs),
web.get("/core/stats", api_hass.stats), web.get("/core/stats", api_hass.stats),
web.post("/core/options", api_hass.options), web.post("/core/options", api_hass.options),
web.post("/core/update", api_hass.update), web.post("/core/update", api_hass.update),
@ -381,11 +447,12 @@ class RestAPI(CoreSysAttributes):
] ]
) )
self._register_advanced_logs("/core", "homeassistant")
# Reroute from legacy # Reroute from legacy
self.webapp.add_routes( self.webapp.add_routes(
[ [
web.get("/homeassistant/info", api_hass.info), web.get("/homeassistant/info", api_hass.info),
web.get("/homeassistant/logs", api_hass.logs),
web.get("/homeassistant/stats", api_hass.stats), web.get("/homeassistant/stats", api_hass.stats),
web.post("/homeassistant/options", api_hass.options), web.post("/homeassistant/options", api_hass.options),
web.post("/homeassistant/restart", api_hass.restart), web.post("/homeassistant/restart", api_hass.restart),
@ -397,6 +464,8 @@ class RestAPI(CoreSysAttributes):
] ]
) )
self._register_advanced_logs("/homeassistant", "homeassistant")
def _register_proxy(self) -> None: def _register_proxy(self) -> None:
"""Register Home Assistant API Proxy.""" """Register Home Assistant API Proxy."""
api_proxy = APIProxy() api_proxy = APIProxy()
@ -443,13 +512,33 @@ class RestAPI(CoreSysAttributes):
), ),
web.get("/addons/{addon}/options/config", api_addons.options_config), web.get("/addons/{addon}/options/config", api_addons.options_config),
web.post("/addons/{addon}/rebuild", api_addons.rebuild), web.post("/addons/{addon}/rebuild", api_addons.rebuild),
web.get("/addons/{addon}/logs", api_addons.logs),
web.post("/addons/{addon}/stdin", api_addons.stdin), web.post("/addons/{addon}/stdin", api_addons.stdin),
web.post("/addons/{addon}/security", api_addons.security), web.post("/addons/{addon}/security", api_addons.security),
web.get("/addons/{addon}/stats", api_addons.stats), web.get("/addons/{addon}/stats", api_addons.stats),
] ]
) )
@api_process_raw(CONTENT_TYPE_TEXT, error_type=CONTENT_TYPE_TEXT)
async def get_addon_logs(request, *args, **kwargs):
addon = api_addons.get_addon_for_request(request)
kwargs["identifier"] = f"addon_{addon.slug}"
return await self._api_host.advanced_logs(request, *args, **kwargs)
self.webapp.add_routes(
[
web.get("/addons/{addon}/logs", get_addon_logs),
web.get(
"/addons/{addon}/logs/follow",
partial(get_addon_logs, follow=True),
),
web.get("/addons/{addon}/logs/boots/{bootid}", get_addon_logs),
web.get(
"/addons/{addon}/logs/boots/{bootid}/follow",
partial(get_addon_logs, follow=True),
),
]
)
# Legacy routing to support requests for not installed addons # Legacy routing to support requests for not installed addons
api_store = APIStore() api_store = APIStore()
api_store.coresys = self.coresys api_store.coresys = self.coresys
@ -547,7 +636,6 @@ class RestAPI(CoreSysAttributes):
[ [
web.get("/dns/info", api_dns.info), web.get("/dns/info", api_dns.info),
web.get("/dns/stats", api_dns.stats), web.get("/dns/stats", api_dns.stats),
web.get("/dns/logs", api_dns.logs),
web.post("/dns/update", api_dns.update), web.post("/dns/update", api_dns.update),
web.post("/dns/options", api_dns.options), web.post("/dns/options", api_dns.options),
web.post("/dns/restart", api_dns.restart), web.post("/dns/restart", api_dns.restart),
@ -555,18 +643,17 @@ class RestAPI(CoreSysAttributes):
] ]
) )
self._register_advanced_logs("/dns", "hassio_dns")
def _register_audio(self) -> None: def _register_audio(self) -> None:
"""Register Audio functions.""" """Register Audio functions."""
api_audio = APIAudio() api_audio = APIAudio()
api_audio.coresys = self.coresys api_audio.coresys = self.coresys
api_host = APIHost()
api_host.coresys = self.coresys
self.webapp.add_routes( self.webapp.add_routes(
[ [
web.get("/audio/info", api_audio.info), web.get("/audio/info", api_audio.info),
web.get("/audio/stats", api_audio.stats), web.get("/audio/stats", api_audio.stats),
web.get("/audio/logs", api_audio.logs),
web.post("/audio/update", api_audio.update), web.post("/audio/update", api_audio.update),
web.post("/audio/restart", api_audio.restart), web.post("/audio/restart", api_audio.restart),
web.post("/audio/reload", api_audio.reload), web.post("/audio/reload", api_audio.reload),
@ -579,6 +666,8 @@ class RestAPI(CoreSysAttributes):
] ]
) )
self._register_advanced_logs("/audio", "hassio_audio")
def _register_mounts(self) -> None: def _register_mounts(self) -> None:
"""Register mounts endpoints.""" """Register mounts endpoints."""
api_mounts = APIMounts() api_mounts = APIMounts()

View File

@ -106,8 +106,8 @@ from ..exceptions import (
PwnedSecret, PwnedSecret,
) )
from ..validate import docker_ports from ..validate import docker_ports
from .const import ATTR_REMOVE_CONFIG, ATTR_SIGNED, CONTENT_TYPE_BINARY from .const import ATTR_REMOVE_CONFIG, ATTR_SIGNED
from .utils import api_process, api_process_raw, api_validate, json_loads from .utils import api_process, api_validate, json_loads
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@ -137,8 +137,8 @@ SCHEMA_UNINSTALL = vol.Schema(
class APIAddons(CoreSysAttributes): class APIAddons(CoreSysAttributes):
"""Handle RESTful API for add-on functions.""" """Handle RESTful API for add-on functions."""
def _extract_addon(self, request: web.Request) -> Addon: def get_addon_for_request(self, request: web.Request) -> Addon:
"""Return addon, throw an exception it it doesn't exist.""" """Return addon, throw an exception if it doesn't exist."""
addon_slug: str = request.match_info.get("addon") addon_slug: str = request.match_info.get("addon")
# Lookup itself # Lookup itself
@ -191,7 +191,7 @@ class APIAddons(CoreSysAttributes):
async def info(self, request: web.Request) -> dict[str, Any]: async def info(self, request: web.Request) -> dict[str, Any]:
"""Return add-on information.""" """Return add-on information."""
addon: AnyAddon = self._extract_addon(request) addon: AnyAddon = self.get_addon_for_request(request)
data = { data = {
ATTR_NAME: addon.name, ATTR_NAME: addon.name,
@ -272,7 +272,7 @@ class APIAddons(CoreSysAttributes):
@api_process @api_process
async def options(self, request: web.Request) -> None: async def options(self, request: web.Request) -> None:
"""Store user options for add-on.""" """Store user options for add-on."""
addon = self._extract_addon(request) addon = self.get_addon_for_request(request)
# Update secrets for validation # Update secrets for validation
await self.sys_homeassistant.secrets.reload() await self.sys_homeassistant.secrets.reload()
@ -307,7 +307,7 @@ class APIAddons(CoreSysAttributes):
@api_process @api_process
async def options_validate(self, request: web.Request) -> None: async def options_validate(self, request: web.Request) -> None:
"""Validate user options for add-on.""" """Validate user options for add-on."""
addon = self._extract_addon(request) addon = self.get_addon_for_request(request)
data = {ATTR_MESSAGE: "", ATTR_VALID: True, ATTR_PWNED: False} data = {ATTR_MESSAGE: "", ATTR_VALID: True, ATTR_PWNED: False}
options = await request.json(loads=json_loads) or addon.options options = await request.json(loads=json_loads) or addon.options
@ -349,7 +349,7 @@ class APIAddons(CoreSysAttributes):
slug: str = request.match_info.get("addon") slug: str = request.match_info.get("addon")
if slug != "self": if slug != "self":
raise APIForbidden("This can be only read by the Add-on itself!") raise APIForbidden("This can be only read by the Add-on itself!")
addon = self._extract_addon(request) addon = self.get_addon_for_request(request)
# Lookup/reload secrets # Lookup/reload secrets
await self.sys_homeassistant.secrets.reload() await self.sys_homeassistant.secrets.reload()
@ -361,7 +361,7 @@ class APIAddons(CoreSysAttributes):
@api_process @api_process
async def security(self, request: web.Request) -> None: async def security(self, request: web.Request) -> None:
"""Store security options for add-on.""" """Store security options for add-on."""
addon = self._extract_addon(request) addon = self.get_addon_for_request(request)
body: dict[str, Any] = await api_validate(SCHEMA_SECURITY, request) body: dict[str, Any] = await api_validate(SCHEMA_SECURITY, request)
if ATTR_PROTECTED in body: if ATTR_PROTECTED in body:
@ -373,7 +373,7 @@ class APIAddons(CoreSysAttributes):
@api_process @api_process
async def stats(self, request: web.Request) -> dict[str, Any]: async def stats(self, request: web.Request) -> dict[str, Any]:
"""Return resource information.""" """Return resource information."""
addon = self._extract_addon(request) addon = self.get_addon_for_request(request)
stats: DockerStats = await addon.stats() stats: DockerStats = await addon.stats()
@ -391,7 +391,7 @@ class APIAddons(CoreSysAttributes):
@api_process @api_process
async def uninstall(self, request: web.Request) -> Awaitable[None]: async def uninstall(self, request: web.Request) -> Awaitable[None]:
"""Uninstall add-on.""" """Uninstall add-on."""
addon = self._extract_addon(request) addon = self.get_addon_for_request(request)
body: dict[str, Any] = await api_validate(SCHEMA_UNINSTALL, request) body: dict[str, Any] = await api_validate(SCHEMA_UNINSTALL, request)
return await asyncio.shield( return await asyncio.shield(
self.sys_addons.uninstall( self.sys_addons.uninstall(
@ -402,40 +402,34 @@ class APIAddons(CoreSysAttributes):
@api_process @api_process
async def start(self, request: web.Request) -> None: async def start(self, request: web.Request) -> None:
"""Start add-on.""" """Start add-on."""
addon = self._extract_addon(request) addon = self.get_addon_for_request(request)
if start_task := await asyncio.shield(addon.start()): if start_task := await asyncio.shield(addon.start()):
await start_task await start_task
@api_process @api_process
def stop(self, request: web.Request) -> Awaitable[None]: def stop(self, request: web.Request) -> Awaitable[None]:
"""Stop add-on.""" """Stop add-on."""
addon = self._extract_addon(request) addon = self.get_addon_for_request(request)
return asyncio.shield(addon.stop()) return asyncio.shield(addon.stop())
@api_process @api_process
async def restart(self, request: web.Request) -> None: async def restart(self, request: web.Request) -> None:
"""Restart add-on.""" """Restart add-on."""
addon: Addon = self._extract_addon(request) addon: Addon = self.get_addon_for_request(request)
if start_task := await asyncio.shield(addon.restart()): if start_task := await asyncio.shield(addon.restart()):
await start_task await start_task
@api_process @api_process
async def rebuild(self, request: web.Request) -> None: async def rebuild(self, request: web.Request) -> None:
"""Rebuild local build add-on.""" """Rebuild local build add-on."""
addon = self._extract_addon(request) addon = self.get_addon_for_request(request)
if start_task := await asyncio.shield(self.sys_addons.rebuild(addon.slug)): if start_task := await asyncio.shield(self.sys_addons.rebuild(addon.slug)):
await start_task await start_task
@api_process_raw(CONTENT_TYPE_BINARY)
def logs(self, request: web.Request) -> Awaitable[bytes]:
"""Return logs from add-on."""
addon = self._extract_addon(request)
return addon.logs()
@api_process @api_process
async def stdin(self, request: web.Request) -> None: async def stdin(self, request: web.Request) -> None:
"""Write to stdin of add-on.""" """Write to stdin of add-on."""
addon = self._extract_addon(request) addon = self.get_addon_for_request(request)
if not addon.with_stdin: if not addon.with_stdin:
raise APIError(f"STDIN not supported the {addon.slug} add-on") raise APIError(f"STDIN not supported the {addon.slug} add-on")

View File

@ -35,8 +35,7 @@ from ..coresys import CoreSysAttributes
from ..exceptions import APIError from ..exceptions import APIError
from ..host.sound import StreamType from ..host.sound import StreamType
from ..validate import version_tag from ..validate import version_tag
from .const import CONTENT_TYPE_BINARY from .utils import api_process, api_validate
from .utils import api_process, api_process_raw, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@ -111,11 +110,6 @@ class APIAudio(CoreSysAttributes):
raise APIError(f"Version {version} is already in use") raise APIError(f"Version {version} is already in use")
await asyncio.shield(self.sys_plugins.audio.update(version)) await asyncio.shield(self.sys_plugins.audio.update(version))
@api_process_raw(CONTENT_TYPE_BINARY)
def logs(self, request: web.Request) -> Awaitable[bytes]:
"""Return Audio Docker logs."""
return self.sys_plugins.audio.logs()
@api_process @api_process
def restart(self, request: web.Request) -> Awaitable[None]: def restart(self, request: web.Request) -> Awaitable[None]:
"""Restart Audio plugin.""" """Restart Audio plugin."""

View File

@ -26,8 +26,8 @@ from ..const import (
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import APIError from ..exceptions import APIError
from ..validate import dns_server_list, version_tag from ..validate import dns_server_list, version_tag
from .const import ATTR_FALLBACK, ATTR_LLMNR, ATTR_MDNS, CONTENT_TYPE_BINARY from .const import ATTR_FALLBACK, ATTR_LLMNR, ATTR_MDNS
from .utils import api_process, api_process_raw, api_validate from .utils import api_process, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@ -105,11 +105,6 @@ class APICoreDNS(CoreSysAttributes):
raise APIError(f"Version {version} is already in use") raise APIError(f"Version {version} is already in use")
await asyncio.shield(self.sys_plugins.dns.update(version)) await asyncio.shield(self.sys_plugins.dns.update(version))
@api_process_raw(CONTENT_TYPE_BINARY)
def logs(self, request: web.Request) -> Awaitable[bytes]:
"""Return DNS Docker logs."""
return self.sys_plugins.dns.logs()
@api_process @api_process
def restart(self, request: web.Request) -> Awaitable[None]: def restart(self, request: web.Request) -> Awaitable[None]:
"""Restart CoreDNS plugin.""" """Restart CoreDNS plugin."""

View File

@ -36,8 +36,7 @@ from ..const import (
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import APIError from ..exceptions import APIError
from ..validate import docker_image, network_port, version_tag from ..validate import docker_image, network_port, version_tag
from .const import CONTENT_TYPE_BINARY from .utils import api_process, api_validate
from .utils import api_process, api_process_raw, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@ -173,11 +172,6 @@ class APIHomeAssistant(CoreSysAttributes):
"""Rebuild Home Assistant.""" """Rebuild Home Assistant."""
return asyncio.shield(self.sys_homeassistant.core.rebuild()) return asyncio.shield(self.sys_homeassistant.core.rebuild())
@api_process_raw(CONTENT_TYPE_BINARY)
def logs(self, request: web.Request) -> Awaitable[bytes]:
"""Return Home Assistant Docker logs."""
return self.sys_homeassistant.core.logs()
@api_process @api_process
async def check(self, request: web.Request) -> None: async def check(self, request: web.Request) -> None:
"""Check configuration of Home Assistant.""" """Check configuration of Home Assistant."""

View File

@ -53,7 +53,7 @@ from .const import (
CONTENT_TYPE_TEXT, CONTENT_TYPE_TEXT,
CONTENT_TYPE_X_LOG, CONTENT_TYPE_X_LOG,
) )
from .utils import api_process, api_validate from .utils import api_process, api_process_raw, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@ -163,7 +163,7 @@ class APIHost(CoreSysAttributes):
raise APIError() from err raise APIError() from err
return possible_offset return possible_offset
@api_process @api_process_raw(CONTENT_TYPE_TEXT, error_type=CONTENT_TYPE_TEXT)
async def advanced_logs( async def advanced_logs(
self, request: web.Request, identifier: str | None = None, follow: bool = False self, request: web.Request, identifier: str | None = None, follow: bool = False
) -> web.StreamResponse: ) -> web.StreamResponse:

View File

@ -23,8 +23,7 @@ from ..const import (
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import APIError from ..exceptions import APIError
from ..validate import version_tag from ..validate import version_tag
from .const import CONTENT_TYPE_BINARY from .utils import api_process, api_validate
from .utils import api_process, api_process_raw, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@ -69,11 +68,6 @@ class APIMulticast(CoreSysAttributes):
raise APIError(f"Version {version} is already in use") raise APIError(f"Version {version} is already in use")
await asyncio.shield(self.sys_plugins.multicast.update(version)) await asyncio.shield(self.sys_plugins.multicast.update(version))
@api_process_raw(CONTENT_TYPE_BINARY)
def logs(self, request: web.Request) -> Awaitable[bytes]:
"""Return Multicast Docker logs."""
return self.sys_plugins.multicast.logs()
@api_process @api_process
def restart(self, request: web.Request) -> Awaitable[None]: def restart(self, request: web.Request) -> Awaitable[None]:
"""Restart Multicast plugin.""" """Restart Multicast plugin."""

View File

@ -49,7 +49,7 @@ from ..store.validate import repositories
from ..utils.sentry import close_sentry, init_sentry from ..utils.sentry import close_sentry, init_sentry
from ..utils.validate import validate_timezone from ..utils.validate import validate_timezone
from ..validate import version_tag, wait_boot from ..validate import version_tag, wait_boot
from .const import CONTENT_TYPE_BINARY from .const import CONTENT_TYPE_TEXT
from .utils import api_process, api_process_raw, api_validate from .utils import api_process, api_process_raw, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@ -229,7 +229,7 @@ class APISupervisor(CoreSysAttributes):
"""Soft restart Supervisor.""" """Soft restart Supervisor."""
return asyncio.shield(self.sys_supervisor.restart()) return asyncio.shield(self.sys_supervisor.restart())
@api_process_raw(CONTENT_TYPE_BINARY) @api_process_raw(CONTENT_TYPE_TEXT, error_type=CONTENT_TYPE_TEXT)
def logs(self, request: web.Request) -> Awaitable[bytes]: def logs(self, request: web.Request) -> Awaitable[bytes]:
"""Return supervisor Docker logs.""" """Return supervisor Docker logs."""
return self.sys_supervisor.logs() return self.sys_supervisor.logs()

View File

@ -25,7 +25,7 @@ from ..exceptions import APIError, APIForbidden, DockerAPIError, HassioError
from ..utils import check_exception_chain, get_message_from_exception_chain from ..utils import check_exception_chain, get_message_from_exception_chain
from ..utils.json import json_dumps, json_loads as json_loads_util from ..utils.json import json_dumps, json_loads as json_loads_util
from ..utils.log_format import format_message from ..utils.log_format import format_message
from .const import CONTENT_TYPE_BINARY from . import const
def excract_supervisor_token(request: web.Request) -> str | None: def excract_supervisor_token(request: web.Request) -> str | None:
@ -91,7 +91,7 @@ def require_home_assistant(method):
return wrap_api return wrap_api
def api_process_raw(content): def api_process_raw(content, *, error_type=None):
"""Wrap content_type into function.""" """Wrap content_type into function."""
def wrap_method(method): def wrap_method(method):
@ -101,15 +101,15 @@ def api_process_raw(content):
"""Return api information.""" """Return api information."""
try: try:
msg_data = await method(api, *args, **kwargs) msg_data = await method(api, *args, **kwargs)
msg_type = content except HassioError as err:
except (APIError, APIForbidden) as err: return api_return_error(
msg_data = str(err).encode() err, error_type=error_type or const.CONTENT_TYPE_BINARY
msg_type = CONTENT_TYPE_BINARY )
except HassioError:
msg_data = b""
msg_type = CONTENT_TYPE_BINARY
return web.Response(body=msg_data, content_type=msg_type) if isinstance(msg_data, (web.Response, web.StreamResponse)):
return msg_data
return web.Response(body=msg_data, content_type=content)
return wrap_api return wrap_api
@ -117,22 +117,35 @@ def api_process_raw(content):
def api_return_error( def api_return_error(
error: Exception | None = None, message: str | None = None error: Exception | None = None,
message: str | None = None,
error_type: str | None = None,
) -> web.Response: ) -> web.Response:
"""Return an API error message.""" """Return an API error message."""
if error and not message: if error and not message:
message = get_message_from_exception_chain(error) message = get_message_from_exception_chain(error)
if check_exception_chain(error, DockerAPIError): if check_exception_chain(error, DockerAPIError):
message = format_message(message) message = format_message(message)
if not message:
message = "Unknown error, see supervisor"
status = 400
if is_api_error := isinstance(error, APIError):
status = error.status
match error_type:
case const.CONTENT_TYPE_TEXT:
return web.Response(body=message, content_type=error_type, status=status)
case const.CONTENT_TYPE_BINARY:
return web.Response(
body=message.encode(), content_type=error_type, status=status
)
case _:
result = { result = {
JSON_RESULT: RESULT_ERROR, JSON_RESULT: RESULT_ERROR,
JSON_MESSAGE: message or "Unknown error, see supervisor", JSON_MESSAGE: message,
} }
status = 400 if is_api_error and error.job_id:
if isinstance(error, APIError):
status = error.status
if error.job_id:
result[JSON_JOB_ID] = error.job_id result[JSON_JOB_ID] = error.job_id
return web.json_response( return web.json_response(

View File

@ -1 +1,66 @@
"""Test for API calls.""" """Test for API calls."""
from unittest.mock import MagicMock
from aiohttp.test_utils import TestClient
from supervisor.host.const import LogFormat
DEFAULT_LOG_RANGE = "entries=:-100:"
async def common_test_api_advanced_logs(
path_prefix: str,
syslog_identifier: str,
api_client: TestClient,
journald_logs: MagicMock,
):
"""Template for tests of endpoints using advanced logs."""
resp = await api_client.get(f"{path_prefix}/logs")
assert resp.status == 200
assert resp.content_type == "text/plain"
journald_logs.assert_called_once_with(
params={"SYSLOG_IDENTIFIER": syslog_identifier},
range_header=DEFAULT_LOG_RANGE,
accept=LogFormat.JOURNAL,
)
journald_logs.reset_mock()
resp = await api_client.get(f"{path_prefix}/logs/follow")
assert resp.status == 200
assert resp.content_type == "text/plain"
journald_logs.assert_called_once_with(
params={"SYSLOG_IDENTIFIER": syslog_identifier, "follow": ""},
range_header=DEFAULT_LOG_RANGE,
accept=LogFormat.JOURNAL,
)
journald_logs.reset_mock()
resp = await api_client.get(f"{path_prefix}/logs/boots/0")
assert resp.status == 200
assert resp.content_type == "text/plain"
journald_logs.assert_called_once_with(
params={"SYSLOG_IDENTIFIER": syslog_identifier, "_BOOT_ID": "ccc"},
range_header=DEFAULT_LOG_RANGE,
accept=LogFormat.JOURNAL,
)
journald_logs.reset_mock()
resp = await api_client.get(f"{path_prefix}/logs/boots/0/follow")
assert resp.status == 200
assert resp.content_type == "text/plain"
journald_logs.assert_called_once_with(
params={
"SYSLOG_IDENTIFIER": syslog_identifier,
"_BOOT_ID": "ccc",
"follow": "",
},
range_header=DEFAULT_LOG_RANGE,
accept=LogFormat.JOURNAL,
)

View File

@ -13,9 +13,11 @@ from supervisor.coresys import CoreSys
from supervisor.docker.addon import DockerAddon from supervisor.docker.addon import DockerAddon
from supervisor.docker.const import ContainerState from supervisor.docker.const import ContainerState
from supervisor.docker.monitor import DockerContainerStateEvent from supervisor.docker.monitor import DockerContainerStateEvent
from supervisor.exceptions import HassioError
from supervisor.store.repository import Repository from supervisor.store.repository import Repository
from ..const import TEST_ADDON_SLUG from ..const import TEST_ADDON_SLUG
from . import common_test_api_advanced_logs
def _create_test_event(name: str, state: ContainerState) -> DockerContainerStateEvent: def _create_test_event(name: str, state: ContainerState) -> DockerContainerStateEvent:
@ -67,17 +69,38 @@ async def test_addons_info_not_installed(
async def test_api_addon_logs( async def test_api_addon_logs(
api_client: TestClient, docker_logs: MagicMock, install_addon_ssh: Addon api_client: TestClient, journald_logs: MagicMock, install_addon_ssh: Addon
): ):
"""Test addon logs.""" """Test addon logs."""
await common_test_api_advanced_logs(
"/addons/local_ssh", "addon_local_ssh", api_client, journald_logs
)
async def test_api_addon_logs_not_installed(api_client: TestClient):
"""Test error is returned for non-existing add-on."""
resp = await api_client.get("/addons/hic_sunt_leones/logs")
assert resp.status == 400
assert resp.content_type == "text/plain"
content = await resp.text()
assert content == "Addon hic_sunt_leones does not exist"
async def test_api_addon_logs_error(
api_client: TestClient,
journald_logs: MagicMock,
docker_logs: MagicMock,
install_addon_ssh: Addon,
):
"""Test errors are properly handled for add-on logs."""
journald_logs.side_effect = HassioError("Something bad happened!")
resp = await api_client.get("/addons/local_ssh/logs") resp = await api_client.get("/addons/local_ssh/logs")
assert resp.status == 200
assert resp.content_type == "application/octet-stream" assert resp.status == 400
content = await resp.read() assert resp.content_type == "text/plain"
assert content.split(b"\n")[0:2] == [ content = await resp.text()
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\x1b[0m", assert content == "Something bad happened!"
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",
]
async def test_api_addon_start_healthcheck( async def test_api_addon_start_healthcheck(

View File

@ -4,14 +4,11 @@ from unittest.mock import MagicMock
from aiohttp.test_utils import TestClient from aiohttp.test_utils import TestClient
from tests.api import common_test_api_advanced_logs
async def test_api_audio_logs(api_client: TestClient, docker_logs: MagicMock):
async def test_api_audio_logs(api_client: TestClient, journald_logs: MagicMock):
"""Test audio logs.""" """Test audio logs."""
resp = await api_client.get("/audio/logs") await common_test_api_advanced_logs(
assert resp.status == 200 "/audio", "hassio_audio", api_client, journald_logs
assert resp.content_type == "application/octet-stream" )
content = await resp.read()
assert content.split(b"\n")[0:2] == [
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\x1b[0m",
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",
]

View File

@ -6,6 +6,7 @@ from aiohttp.test_utils import TestClient
from supervisor.coresys import CoreSys from supervisor.coresys import CoreSys
from supervisor.dbus.resolved import Resolved from supervisor.dbus.resolved import Resolved
from tests.api import common_test_api_advanced_logs
from tests.dbus_service_mocks.base import DBusServiceMock from tests.dbus_service_mocks.base import DBusServiceMock
from tests.dbus_service_mocks.resolved import Resolved as ResolvedService from tests.dbus_service_mocks.resolved import Resolved as ResolvedService
@ -64,13 +65,6 @@ async def test_options(api_client: TestClient, coresys: CoreSys):
restart.assert_called_once() restart.assert_called_once()
async def test_api_dns_logs(api_client: TestClient, docker_logs: MagicMock): async def test_api_dns_logs(api_client: TestClient, journald_logs: MagicMock):
"""Test dns logs.""" """Test dns logs."""
resp = await api_client.get("/dns/logs") await common_test_api_advanced_logs("/dns", "hassio_dns", api_client, journald_logs)
assert resp.status == 200
assert resp.content_type == "application/octet-stream"
content = await resp.read()
assert content.split(b"\n")[0:2] == [
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\x1b[0m",
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",
]

View File

@ -8,22 +8,21 @@ import pytest
from supervisor.coresys import CoreSys from supervisor.coresys import CoreSys
from supervisor.homeassistant.module import HomeAssistant from supervisor.homeassistant.module import HomeAssistant
from tests.api import common_test_api_advanced_logs
from tests.common import load_json_fixture from tests.common import load_json_fixture
@pytest.mark.parametrize("legacy_route", [True, False]) @pytest.mark.parametrize("legacy_route", [True, False])
async def test_api_core_logs( async def test_api_core_logs(
api_client: TestClient, docker_logs: MagicMock, legacy_route: bool api_client: TestClient, journald_logs: MagicMock, legacy_route: bool
): ):
"""Test core logs.""" """Test core logs."""
resp = await api_client.get(f"/{'homeassistant' if legacy_route else 'core'}/logs") await common_test_api_advanced_logs(
assert resp.status == 200 f"/{'homeassistant' if legacy_route else 'core'}",
assert resp.content_type == "application/octet-stream" "homeassistant",
content = await resp.read() api_client,
assert content.split(b"\n")[0:2] == [ journald_logs,
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\x1b[0m", )
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",
]
async def test_api_stats(api_client: TestClient, coresys: CoreSys): async def test_api_stats(api_client: TestClient, coresys: CoreSys):

View File

@ -310,15 +310,17 @@ async def test_advanced_logs_errors(api_client: TestClient):
"""Test advanced logging API errors.""" """Test advanced logging API errors."""
# coresys = coresys_logs_control # coresys = coresys_logs_control
resp = await api_client.get("/host/logs") resp = await api_client.get("/host/logs")
result = await resp.json() assert resp.content_type == "text/plain"
assert result["result"] == "error" assert resp.status == 400
assert result["message"] == "No systemd-journal-gatewayd Unix socket available" content = await resp.text()
assert content == "No systemd-journal-gatewayd Unix socket available"
headers = {"Accept": "application/json"} headers = {"Accept": "application/json"}
resp = await api_client.get("/host/logs", headers=headers) resp = await api_client.get("/host/logs", headers=headers)
result = await resp.json() assert resp.content_type == "text/plain"
assert result["result"] == "error" assert resp.status == 400
content = await resp.text()
assert ( assert (
result["message"] content
== "Invalid content type requested. Only text/plain and text/x-log supported for now." == "Invalid content type requested. Only text/plain and text/x-log supported for now."
) )

View File

@ -4,14 +4,11 @@ from unittest.mock import MagicMock
from aiohttp.test_utils import TestClient from aiohttp.test_utils import TestClient
from tests.api import common_test_api_advanced_logs
async def test_api_multicast_logs(api_client: TestClient, docker_logs: MagicMock):
async def test_api_multicast_logs(api_client: TestClient, journald_logs: MagicMock):
"""Test multicast logs.""" """Test multicast logs."""
resp = await api_client.get("/multicast/logs") await common_test_api_advanced_logs(
assert resp.status == 200 "/multicast", "hassio_multicast", api_client, journald_logs
assert resp.content_type == "application/octet-stream" )
content = await resp.read()
assert content.split(b"\n")[0:2] == [
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\x1b[0m",
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",
]

View File

@ -9,6 +9,7 @@ from supervisor.coresys import CoreSys
from supervisor.exceptions import StoreGitError, StoreNotFound from supervisor.exceptions import StoreGitError, StoreNotFound
from supervisor.store.repository import Repository from supervisor.store.repository import Repository
from tests.api import common_test_api_advanced_logs
from tests.dbus_service_mocks.base import DBusServiceMock from tests.dbus_service_mocks.base import DBusServiceMock
from tests.dbus_service_mocks.os_agent import OSAgent as OSAgentService from tests.dbus_service_mocks.os_agent import OSAgent as OSAgentService
@ -148,11 +149,27 @@ async def test_api_supervisor_options_diagnostics(
assert coresys.dbus.agent.diagnostics is False assert coresys.dbus.agent.diagnostics is False
async def test_api_supervisor_logs(api_client: TestClient, docker_logs: MagicMock): async def test_api_supervisor_logs(api_client: TestClient, journald_logs: MagicMock):
"""Test supervisor logs.""" """Test supervisor logs."""
await common_test_api_advanced_logs(
"/supervisor", "hassio_supervisor", api_client, journald_logs
)
async def test_api_supervisor_fallback(
api_client: TestClient, journald_logs: MagicMock, docker_logs: MagicMock
):
"""Check that supervisor logs read from container logs if reading from journald gateway fails badly."""
journald_logs.side_effect = OSError("Something bad happened!")
with patch("supervisor.api._LOGGER.exception") as logger:
resp = await api_client.get("/supervisor/logs") resp = await api_client.get("/supervisor/logs")
logger.assert_called_once_with(
"Failed to get supervisor logs using advanced_logs API"
)
assert resp.status == 200 assert resp.status == 200
assert resp.content_type == "application/octet-stream" assert resp.content_type == "text/plain"
content = await resp.read() content = await resp.read()
assert content.split(b"\n")[0:2] == [ assert content.split(b"\n")[0:2] == [
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\x1b[0m", 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\x1b[0m",