Files
supervisor/supervisor/os/manager.py
Mike Degatano 3cc6bd19ad Mark system as unhealthy on OSError Bad message errors (#4750)
* Bad message error marks system as unhealthy

* Finish adding test cases for changes

* Rename test file for uniqueness

* bad_message to oserror_bad_message

* Omit some checks and check for network mounts
2023-12-21 18:05:29 +01:00

242 lines
8.1 KiB
Python

"""OS support on supervisor."""
from collections.abc import Awaitable
import errno
import logging
from pathlib import Path
import aiohttp
from awesomeversion import AwesomeVersion, AwesomeVersionException
from cpe import CPE
from ..coresys import CoreSys, CoreSysAttributes
from ..dbus.agent.boards.const import BOARD_NAME_SUPERVISED
from ..dbus.rauc import RaucState
from ..exceptions import DBusError, HassOSJobError, HassOSUpdateError
from ..jobs.const import JobCondition, JobExecutionLimit
from ..jobs.decorator import Job
from ..resolution.const import UnhealthyReason
from .data_disk import DataDisk
_LOGGER: logging.Logger = logging.getLogger(__name__)
class OSManager(CoreSysAttributes):
"""OS interface inside supervisor."""
def __init__(self, coresys: CoreSys):
"""Initialize HassOS handler."""
self.coresys: CoreSys = coresys
self._datadisk: DataDisk = DataDisk(coresys)
self._available: bool = False
self._version: AwesomeVersion | None = None
self._board: str | None = None
self._os_name: str | None = None
@property
def available(self) -> bool:
"""Return True, if HassOS on host."""
return self._available
@property
def version(self) -> AwesomeVersion | None:
"""Return version of HassOS."""
return self._version
@property
def latest_version(self) -> AwesomeVersion | None:
"""Return version of HassOS."""
return self.sys_updater.version_hassos
@property
def need_update(self) -> bool:
"""Return true if a HassOS update is available."""
try:
return self.version < self.latest_version
except (AwesomeVersionException, TypeError):
return False
@property
def board(self) -> str | None:
"""Return board name."""
return self._board
@property
def os_name(self) -> str | None:
"""Return OS name."""
return self._os_name
@property
def datadisk(self) -> DataDisk:
"""Return Operating-System datadisk."""
return self._datadisk
def _get_download_url(self, version: AwesomeVersion) -> str:
raw_url = self.sys_updater.ota_url
if raw_url is None:
raise HassOSUpdateError("Don't have an URL for OTA updates!", _LOGGER.error)
update_board = self.board
update_os_name = self.os_name
# OS version 6 and later renamed intel-nuc to generic-x86-64...
if update_board == "intel-nuc" and version >= 6.0:
update_board = "generic-x86-64"
# The OS name used to be hassos before renaming to haos...
if version < 6.0:
update_os_name = "hassos"
else:
update_os_name = "haos"
url = raw_url.format(
version=str(version), board=update_board, os_name=update_os_name
)
return url
async def _download_raucb(self, url: str, raucb: Path) -> None:
"""Download rauc bundle (OTA) from URL."""
_LOGGER.info("Fetch OTA update from %s", url)
try:
timeout = aiohttp.ClientTimeout(total=60 * 60, connect=180)
async with self.sys_websession.get(url, timeout=timeout) as request:
if request.status != 200:
raise HassOSUpdateError(
f"Error raised from OTA Webserver: {request.status}",
_LOGGER.error,
)
# Download RAUCB file
with raucb.open("wb") as ota_file:
while True:
chunk = await request.content.read(1_048_576)
if not chunk:
break
ota_file.write(chunk)
_LOGGER.info("Completed download of OTA update file %s", raucb)
except (aiohttp.ClientError, TimeoutError) as err:
self.sys_supervisor.connectivity = False
raise HassOSUpdateError(
f"Can't fetch OTA update from {url}: {err!s}", _LOGGER.error
) from err
except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
raise HassOSUpdateError(
f"Can't write OTA file: {err!s}", _LOGGER.error
) from err
async def load(self) -> None:
"""Load HassOS data."""
try:
if not self.sys_host.info.cpe:
raise NotImplementedError()
cpe = CPE(self.sys_host.info.cpe)
os_name = cpe.get_product()[0]
if os_name not in ("hassos", "haos"):
self._board = BOARD_NAME_SUPERVISED.lower()
raise NotImplementedError()
except NotImplementedError:
_LOGGER.info("No Home Assistant Operating System found")
return
# Store meta data
self._available = True
self.sys_host.supported_features.cache_clear()
self._version = AwesomeVersion(cpe.get_version()[0])
self._board = cpe.get_target_hardware()[0]
self._os_name = cpe.get_product()[0]
await self.datadisk.load()
_LOGGER.info(
"Detect Home Assistant Operating System %s / BootSlot %s",
self.version,
self.sys_dbus.rauc.boot_slot,
)
@Job(
name="os_manager_config_sync",
conditions=[JobCondition.HAOS],
on_condition=HassOSJobError,
)
async def config_sync(self) -> Awaitable[None]:
"""Trigger a host config reload from usb.
Return a coroutine.
"""
_LOGGER.info(
"Synchronizing configuration from USB with Home Assistant Operating System."
)
await self.sys_host.services.restart("hassos-config.service")
@Job(
name="os_manager_update",
conditions=[
JobCondition.HAOS,
JobCondition.INTERNET_SYSTEM,
JobCondition.RUNNING,
JobCondition.SUPERVISOR_UPDATED,
],
limit=JobExecutionLimit.ONCE,
on_condition=HassOSJobError,
)
async def update(self, version: AwesomeVersion | None = None) -> None:
"""Update HassOS system."""
version = version or self.latest_version
# Check installed version
if version == self.version:
raise HassOSUpdateError(
f"Version {version!s} is already installed", _LOGGER.warning
)
# Fetch files from internet
ota_url = self._get_download_url(version)
int_ota = Path(self.sys_config.path_tmp, f"hassos-{version!s}.raucb")
await self._download_raucb(ota_url, int_ota)
ext_ota = Path(self.sys_config.path_extern_tmp, int_ota.name)
try:
async with self.sys_dbus.rauc.signal_completed() as signal:
# Start listening for signals before triggering install
# This prevents a race condition with install complete signal
await self.sys_dbus.rauc.install(ext_ota)
completed = await signal.wait_for_signal()
except DBusError as err:
raise HassOSUpdateError("Rauc communication error", _LOGGER.error) from err
finally:
int_ota.unlink()
# Update success
if 0 in completed:
_LOGGER.info(
"Install of Home Assistant Operating System %s success", version
)
self.sys_create_task(self.sys_host.control.reboot())
return
# Update failed
await self.sys_dbus.rauc.update()
_LOGGER.error(
"Home Assistant Operating System update failed with: %s",
self.sys_dbus.rauc.last_error,
)
raise HassOSUpdateError()
@Job(name="os_manager_mark_healthy", conditions=[JobCondition.HAOS], internal=True)
async def mark_healthy(self) -> None:
"""Set booted partition as good for rauc."""
try:
response = await self.sys_dbus.rauc.mark(RaucState.GOOD, "booted")
except DBusError:
_LOGGER.error("Can't mark booted partition as healthy!")
else:
_LOGGER.info("Rauc: %s - %s", self.sys_dbus.rauc.boot_slot, response[1])