From 271e4f0cc4b6248113b59b3c7beb7f220848b57e Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 17 Sep 2021 15:01:07 +0200 Subject: [PATCH] Support OS-Agent Data disk (#3120) * Support OS-Agent Data disk * fix lint * add tests * Fix empty path * revert change * Using as_posix() * clean not needed cast * rename * Rename files --- pylintrc | 2 +- supervisor/api/__init__.py | 1 + supervisor/api/const.py | 2 + supervisor/api/info.py | 2 +- supervisor/api/os.py | 25 +++++++--- supervisor/bootstrap.py | 4 +- supervisor/core.py | 4 +- supervisor/coresys.py | 32 ++++++------- supervisor/host/manager.py | 2 +- supervisor/jobs/const.py | 1 + supervisor/jobs/decorator.py | 11 ++++- supervisor/misc/filter.py | 6 +-- supervisor/os/__init__.py | 1 + supervisor/os/data_disk.py | 47 +++++++++++++++++++ supervisor/{hassos.py => os/manager.py} | 23 +++++---- .../evaluations/operating_system.py | 2 +- supervisor/updater.py | 4 +- supervisor/utils/json.py | 4 +- tests/api/test_os.py | 19 ++++++++ tests/jobs/test_job_decorator.py | 4 +- tests/os/__init__.py | 1 + tests/{test_hassos.py => os/test_manager.py} | 18 +++---- .../test_evaluate_operating_system.py | 4 +- 23 files changed, 158 insertions(+), 61 deletions(-) create mode 100644 supervisor/os/__init__.py create mode 100644 supervisor/os/data_disk.py rename supervisor/{hassos.py => os/manager.py} (92%) create mode 100644 tests/api/test_os.py create mode 100644 tests/os/__init__.py rename tests/{test_hassos.py => os/test_manager.py} (77%) diff --git a/pylintrc b/pylintrc index c0cee4347..6eedc8683 100644 --- a/pylintrc +++ b/pylintrc @@ -2,7 +2,7 @@ reports=no jobs=2 -good-names=id,i,j,k,ex,Run,_,fp,T +good-names=id,i,j,k,ex,Run,_,fp,T,os extension-pkg-whitelist= ciso8601 diff --git a/supervisor/api/__init__.py b/supervisor/api/__init__.py index e698f20e0..fe4ab15a2 100644 --- a/supervisor/api/__init__.py +++ b/supervisor/api/__init__.py @@ -145,6 +145,7 @@ class RestAPI(CoreSysAttributes): web.get("/os/info", api_os.info), web.post("/os/update", api_os.update), web.post("/os/config/sync", api_os.config_sync), + web.post("/os/datadisk/move", api_os.migrate_data), ] ) diff --git a/supervisor/api/const.py b/supervisor/api/const.py index 90db0d4a4..f0e423f76 100644 --- a/supervisor/api/const.py +++ b/supervisor/api/const.py @@ -4,3 +4,5 @@ ATTR_USE_RTC = "use_rtc" ATTR_USE_NTP = "use_ntp" ATTR_DT_UTC = "dt_utc" ATTR_DT_SYNCHRONIZED = "dt_synchronized" +ATTR_DISK_DATA = "disk_data" +ATTR_DEVICE = "device" diff --git a/supervisor/api/info.py b/supervisor/api/info.py index 10f88c722..77ded5aa9 100644 --- a/supervisor/api/info.py +++ b/supervisor/api/info.py @@ -36,7 +36,7 @@ class APIInfo(CoreSysAttributes): return { ATTR_SUPERVISOR: self.sys_supervisor.version, ATTR_HOMEASSISTANT: self.sys_homeassistant.version, - ATTR_HASSOS: self.sys_hassos.version, + ATTR_HASSOS: self.sys_os.version, ATTR_DOCKER: self.sys_docker.info.version, ATTR_HOSTNAME: self.sys_host.info.hostname, ATTR_OPERATING_SYSTEM: self.sys_host.info.operating_system, diff --git a/supervisor/api/os.py b/supervisor/api/os.py index 1ad48fe8d..8965ea005 100644 --- a/supervisor/api/os.py +++ b/supervisor/api/os.py @@ -1,6 +1,7 @@ """Init file for Supervisor HassOS RESTful API.""" import asyncio import logging +from pathlib import Path from typing import Any, Awaitable, Dict from aiohttp import web @@ -15,11 +16,13 @@ from ..const import ( ) from ..coresys import CoreSysAttributes from ..validate import version_tag +from .const import ATTR_DEVICE, ATTR_DISK_DATA from .utils import api_process, api_validate _LOGGER: logging.Logger = logging.getLogger(__name__) SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): version_tag}) +SCHEMA_DISK = vol.Schema({vol.Required(ATTR_DEVICE): vol.All(str, vol.Coerce(Path))}) class APIOS(CoreSysAttributes): @@ -29,22 +32,30 @@ class APIOS(CoreSysAttributes): async def info(self, request: web.Request) -> Dict[str, Any]: """Return OS information.""" return { - ATTR_VERSION: self.sys_hassos.version, - ATTR_VERSION_LATEST: self.sys_hassos.latest_version, - ATTR_UPDATE_AVAILABLE: self.sys_hassos.need_update, - ATTR_BOARD: self.sys_hassos.board, + ATTR_VERSION: self.sys_os.version, + ATTR_VERSION_LATEST: self.sys_os.latest_version, + ATTR_UPDATE_AVAILABLE: self.sys_os.need_update, + ATTR_BOARD: self.sys_os.board, ATTR_BOOT: self.sys_dbus.rauc.boot_slot, + ATTR_DISK_DATA: self.sys_os.datadisk.disk_used, } @api_process async def update(self, request: web.Request) -> None: """Update OS.""" body = await api_validate(SCHEMA_VERSION, request) - version = body.get(ATTR_VERSION, self.sys_hassos.latest_version) + version = body.get(ATTR_VERSION, self.sys_os.latest_version) - await asyncio.shield(self.sys_hassos.update(version)) + await asyncio.shield(self.sys_os.update(version)) @api_process def config_sync(self, request: web.Request) -> Awaitable[None]: """Trigger config reload on OS.""" - return asyncio.shield(self.sys_hassos.config_sync()) + return asyncio.shield(self.sys_os.config_sync()) + + @api_process + async def migrate_data(self, request: web.Request) -> None: + """Trigger data disk migration on Host.""" + body = await api_validate(SCHEMA_DISK, request) + + await asyncio.shield(self.sys_os.datadisk.migrate_disk(body[ATTR_DEVICE])) diff --git a/supervisor/bootstrap.py b/supervisor/bootstrap.py index b68a0a67f..be75daa7b 100644 --- a/supervisor/bootstrap.py +++ b/supervisor/bootstrap.py @@ -38,13 +38,13 @@ from .coresys import CoreSys from .dbus.manager import DBusManager from .discovery import Discovery from .hardware.module import HardwareManager -from .hassos import HassOS from .homeassistant.module import HomeAssistant from .host.manager import HostManager from .ingress import Ingress from .misc.filter import filter_data from .misc.scheduler import Scheduler from .misc.tasks import Tasks +from .os.manager import OSManager from .plugins.manager import PluginManager from .resolution.module import ResolutionManager from .security import Security @@ -81,7 +81,7 @@ async def initialize_coresys() -> CoreSys: coresys.store = StoreManager(coresys) coresys.discovery = Discovery(coresys) coresys.dbus = DBusManager(coresys) - coresys.hassos = HassOS(coresys) + coresys.os = OSManager(coresys) coresys.scheduler = Scheduler(coresys) coresys.security = Security(coresys) coresys.bus = Bus(coresys) diff --git a/supervisor/core.py b/supervisor/core.py index 5ffdfb71e..67c18f8e4 100644 --- a/supervisor/core.py +++ b/supervisor/core.py @@ -125,7 +125,7 @@ class Core(CoreSysAttributes): # Load CPU/Arch self.sys_arch.load(), # Load HassOS - self.sys_hassos.load(), + self.sys_os.load(), # Load Stores self.sys_store.load(), # Load Add-ons @@ -169,7 +169,7 @@ class Core(CoreSysAttributes): ) # Mark booted partition as healthy - await self.sys_hassos.mark_healthy() + await self.sys_os.mark_healthy() # On release channel, try update itself if self.sys_supervisor.need_update: diff --git a/supervisor/coresys.py b/supervisor/coresys.py index 1d748172a..9d7a8696f 100644 --- a/supervisor/coresys.py +++ b/supervisor/coresys.py @@ -26,7 +26,7 @@ if TYPE_CHECKING: from .dbus.manager import DBusManager from .discovery import Discovery from .hardware.module import HardwareManager - from .hassos import HassOS + from .os.manager import OSManager from .homeassistant.module import HomeAssistant from .host.manager import HostManager from .ingress import Ingress @@ -79,7 +79,7 @@ class CoreSys: self._host: Optional[HostManager] = None self._ingress: Optional[Ingress] = None self._dbus: Optional[DBusManager] = None - self._hassos: Optional[HassOS] = None + self._os: Optional[OSManager] = None self._services: Optional[ServiceManager] = None self._scheduler: Optional[Scheduler] = None self._store: Optional[StoreManager] = None @@ -411,18 +411,18 @@ class CoreSys: self._ingress = value @property - def hassos(self) -> HassOS: - """Return HassOS object.""" - if self._hassos is None: - raise RuntimeError("HassOS not set!") - return self._hassos + def os(self) -> OSManager: + """Return OSManager object.""" + if self._os is None: + raise RuntimeError("OSManager not set!") + return self._os - @hassos.setter - def hassos(self, value: HassOS) -> None: - """Set a HassOS object.""" - if self._hassos: - raise RuntimeError("HassOS already set!") - self._hassos = value + @os.setter + def os(self, value: OSManager) -> None: + """Set a OSManager object.""" + if self._os: + raise RuntimeError("OSManager already set!") + self._os = value @property def resolution(self) -> ResolutionManager: @@ -650,9 +650,9 @@ class CoreSysAttributes: return self.coresys.ingress @property - def sys_hassos(self) -> HassOS: - """Return HassOS object.""" - return self.coresys.hassos + def sys_os(self) -> OSManager: + """Return OSManager object.""" + return self.coresys.os @property def sys_resolution(self) -> ResolutionManager: diff --git a/supervisor/host/manager.py b/supervisor/host/manager.py index aa31c6dee..52e1b2ed6 100644 --- a/supervisor/host/manager.py +++ b/supervisor/host/manager.py @@ -91,7 +91,7 @@ class HostManager(CoreSysAttributes): if self.sys_dbus.agent.is_connected: features.append(HostFeature.AGENT) - if self.sys_hassos.available: + if self.sys_os.available: features.append(HostFeature.HAOS) return features diff --git a/supervisor/jobs/const.py b/supervisor/jobs/const.py index 7b829a91b..b275c627a 100644 --- a/supervisor/jobs/const.py +++ b/supervisor/jobs/const.py @@ -18,6 +18,7 @@ class JobCondition(str, Enum): INTERNET_HOST = "internet_host" RUNNING = "running" HAOS = "haos" + OS_AGENT = "os_agent" class JobExecutionLimit(str, Enum): diff --git a/supervisor/jobs/decorator.py b/supervisor/jobs/decorator.py index 00c27bfcb..ca1466b89 100644 --- a/supervisor/jobs/decorator.py +++ b/supervisor/jobs/decorator.py @@ -10,6 +10,7 @@ import sentry_sdk from ..const import CoreState from ..coresys import CoreSysAttributes from ..exceptions import HassioError, JobConditionException, JobException +from ..host.const import HostFeature from ..resolution.const import MINIMUM_FREE_SPACE_THRESHOLD, ContextType, IssueType from .const import JobCondition, JobExecutionLimit @@ -167,11 +168,19 @@ class Job(CoreSysAttributes): f"'{self._method.__qualname__}' blocked from execution, no host internet connection" ) - if JobCondition.HAOS in self.conditions and not self.sys_hassos.available: + if JobCondition.HAOS in self.conditions and not self.sys_os.available: raise JobConditionException( f"'{self._method.__qualname__}' blocked from execution, no Home Assistant OS available" ) + if ( + JobCondition.OS_AGENT in self.conditions + and HostFeature.AGENT not in self.sys_host.features + ): + raise JobConditionException( + f"'{self._method.__qualname__}' blocked from execution, no Home Assistant OS-Agent available" + ) + async def _acquire_exection_limit(self) -> None: """Process exection limits.""" if self.limit not in ( diff --git a/supervisor/misc/filter.py b/supervisor/misc/filter.py index 2d3d1072f..c2c651840 100644 --- a/supervisor/misc/filter.py +++ b/supervisor/misc/filter.py @@ -56,7 +56,7 @@ def filter_data(coresys: CoreSys, event: dict, hint: dict) -> dict: }, "host": { "arch": coresys.arch.default, - "board": coresys.hassos.board, + "board": coresys.os.board, "deployment": coresys.host.info.deployment, "disk_free_space": coresys.host.info.free_space, "host": coresys.host.info.operating_system, @@ -72,7 +72,7 @@ def filter_data(coresys: CoreSys, event: dict, hint: dict) -> dict: "docker": coresys.docker.info.version, "multicast": coresys.plugins.multicast.version, "observer": coresys.plugins.observer.version, - "os": coresys.hassos.version, + "os": coresys.os.version, "supervisor": coresys.supervisor.version, }, "resolution": { @@ -87,7 +87,7 @@ def filter_data(coresys: CoreSys, event: dict, hint: dict) -> dict: ) event.setdefault("tags", []).extend( [ - ["installation_type", "os" if coresys.hassos.available else "supervised"], + ["installation_type", "os" if coresys.os.available else "supervised"], ["machine", coresys.machine], ], ) diff --git a/supervisor/os/__init__.py b/supervisor/os/__init__.py new file mode 100644 index 000000000..f5dc261c2 --- /dev/null +++ b/supervisor/os/__init__.py @@ -0,0 +1 @@ +"""Home Assistant Operating-System backend.""" diff --git a/supervisor/os/data_disk.py b/supervisor/os/data_disk.py new file mode 100644 index 000000000..009059b2b --- /dev/null +++ b/supervisor/os/data_disk.py @@ -0,0 +1,47 @@ +"""Home Assistant Operating-System DataDisk.""" +import logging +from pathlib import Path +from typing import Optional + +from ..coresys import CoreSys, CoreSysAttributes +from ..exceptions import DBusError, HassOSError, HassOSJobError, HostError +from ..jobs.const import JobCondition, JobExecutionLimit +from ..jobs.decorator import Job + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +class DataDisk(CoreSysAttributes): + """Handle DataDisk feature from OS.""" + + def __init__(self, coresys: CoreSys) -> None: + """Initialize DataDisk.""" + self.coresys = coresys + + @property + def disk_used(self) -> Optional[Path]: + """Return Path to used Disk for data.""" + return self.sys_dbus.agent.datadisk.current_device + + @Job( + conditions=[JobCondition.HAOS, JobCondition.OS_AGENT, JobCondition.HEALTHY], + limit=JobExecutionLimit.ONCE, + on_condition=HassOSJobError, + ) + async def migrate_disk(self, new_disk: Path) -> None: + """Move data partition to a new disk.""" + # Need some error handling, but we need know what disk_used will return + try: + await self.sys_dbus.agent.datadisk.change_device(new_disk) + except DBusError as err: + raise HassOSError( + f"Can't move data partition to {new_disk!s}: {err!s}", _LOGGER.error + ) from err + + try: + await self.sys_host.control.reboot() + except HostError as err: + raise HassOSError( + f"Can't restart device to finish disk migration: {err!s}", + _LOGGER.warning, + ) from err diff --git a/supervisor/hassos.py b/supervisor/os/manager.py similarity index 92% rename from supervisor/hassos.py rename to supervisor/os/manager.py index 0a7d4ad39..9a3f0fe48 100644 --- a/supervisor/hassos.py +++ b/supervisor/os/manager.py @@ -1,4 +1,4 @@ -"""HassOS support on supervisor.""" +"""OS support on supervisor.""" import asyncio import logging from pathlib import Path @@ -8,21 +8,23 @@ import aiohttp from awesomeversion import AwesomeVersion, AwesomeVersionException from cpe import CPE -from .coresys import CoreSys, CoreSysAttributes -from .dbus.rauc import RaucState -from .exceptions import DBusError, HassOSJobError, HassOSUpdateError -from .jobs.const import JobCondition, JobExecutionLimit -from .jobs.decorator import Job +from ..coresys import CoreSys, CoreSysAttributes +from ..dbus.rauc import RaucState +from ..exceptions import DBusError, HassOSJobError, HassOSUpdateError +from ..jobs.const import JobCondition, JobExecutionLimit +from ..jobs.decorator import Job +from .data_disk import DataDisk _LOGGER: logging.Logger = logging.getLogger(__name__) -class HassOS(CoreSysAttributes): - """HassOS interface inside supervisor.""" +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: Optional[AwesomeVersion] = None self._board: Optional[str] = None @@ -61,6 +63,11 @@ class HassOS(CoreSysAttributes): """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: diff --git a/supervisor/resolution/evaluations/operating_system.py b/supervisor/resolution/evaluations/operating_system.py index eff42ec32..80ff781a2 100644 --- a/supervisor/resolution/evaluations/operating_system.py +++ b/supervisor/resolution/evaluations/operating_system.py @@ -34,6 +34,6 @@ class EvaluateOperatingSystem(EvaluateBase): async def evaluate(self): """Run evaluation.""" - if self.sys_hassos.available: + if self.sys_os.available: return False return self.sys_host.info.operating_system not in SUPPORTED_OS diff --git a/supervisor/updater.py b/supervisor/updater.py index e5257c538..dad4494b8 100644 --- a/supervisor/updater.py +++ b/supervisor/updater.py @@ -241,10 +241,10 @@ class Updater(FileConfiguration, CoreSysAttributes): ) # Update HassOS version - if self.sys_hassos.board: + if self.sys_os.board: events.append("os") self._data[ATTR_HASSOS] = AwesomeVersion( - data["hassos"][self.sys_hassos.board] + data["hassos"][self.sys_os.board] ) self._data[ATTR_OTA] = data["ota"] diff --git a/supervisor/utils/json.py b/supervisor/utils/json.py index d4b22caa7..789fdd112 100644 --- a/supervisor/utils/json.py +++ b/supervisor/utils/json.py @@ -25,9 +25,7 @@ class JSONEncoder(json.JSONEncoder): if isinstance(o, set): return list(o) if isinstance(o, Path): - return str(o) - if hasattr(o, "as_dict"): - return o.as_dict() + return o.as_posix() return json.JSONEncoder.default(self, o) diff --git a/tests/api/test_os.py b/tests/api/test_os.py new file mode 100644 index 000000000..857310ff9 --- /dev/null +++ b/tests/api/test_os.py @@ -0,0 +1,19 @@ +"""Test OS API.""" +import pytest + + +@pytest.mark.asyncio +async def test_api_os_info(api_client): + """Test docker info api.""" + resp = await api_client.get("/os/info") + result = await resp.json() + + for attr in ( + "version", + "version_latest", + "update_available", + "board", + "boot", + "disk_data", + ): + assert attr in result["data"] diff --git a/tests/jobs/test_job_decorator.py b/tests/jobs/test_job_decorator.py index 522f3190f..0e21ce184 100644 --- a/tests/jobs/test_job_decorator.py +++ b/tests/jobs/test_job_decorator.py @@ -119,10 +119,10 @@ async def test_haos(coresys: CoreSys): return True test = TestClass(coresys) - coresys.hassos._available = True + coresys.os._available = True assert await test.execute() - coresys.hassos._available = False + coresys.os._available = False assert not await test.execute() diff --git a/tests/os/__init__.py b/tests/os/__init__.py new file mode 100644 index 000000000..ca2535f45 --- /dev/null +++ b/tests/os/__init__.py @@ -0,0 +1 @@ +"""Home Assistant Operating-System backend tests.""" diff --git a/tests/test_hassos.py b/tests/os/test_manager.py similarity index 77% rename from tests/test_hassos.py rename to tests/os/test_manager.py index 36289eb30..a52b89ce1 100644 --- a/tests/test_hassos.py +++ b/tests/os/test_manager.py @@ -11,8 +11,8 @@ from supervisor.coresys import CoreSys @pytest.mark.asyncio async def test_ota_url_generic_x86_64_rename(coresys: CoreSys) -> None: """Test download URL generated.""" - coresys.hassos._board = "intel-nuc" - coresys.hassos._version = AwesomeVersion("5.13") + coresys.os._board = "intel-nuc" + coresys.os._version = AwesomeVersion("5.13") await coresys.updater.fetch_data() version6 = AwesomeVersion("6.0") @@ -20,7 +20,7 @@ async def test_ota_url_generic_x86_64_rename(coresys: CoreSys) -> None: version=str(version6), board="generic-x86-64", os_name="haos" ) - assert coresys.hassos._get_download_url(version6) == url + assert coresys.os._get_download_url(version6) == url def test_ota_url_os_name(coresys: CoreSys) -> None: @@ -33,11 +33,11 @@ def test_ota_url_os_name(coresys: CoreSys) -> None: url = "https://github.com/home-assistant/operating-system/releases/download/{version}/{os_name}_{board}-{version}.raucb" url_formatted = url.format(version=versionstr, board=board, os_name=os_name) - coresys.hassos._board = board - coresys.hassos._os_name = os_name + coresys.os._board = board + coresys.os._os_name = os_name coresys.updater._data = {"ota": url} - url = coresys.hassos._get_download_url(AwesomeVersion(versionstr)) + url = coresys.os._get_download_url(AwesomeVersion(versionstr)) assert url == url_formatted @@ -51,9 +51,9 @@ def test_ota_url_os_name_rel_5_downgrade(coresys: CoreSys) -> None: url = "https://github.com/home-assistant/operating-system/releases/download/{version}/{os_name}_{board}-{version}.raucb" url_formatted = url.format(version=versionstr, board=board, os_name="hassos") - coresys.hassos._board = board - coresys.hassos._os_name = "haos" + coresys.os._board = board + coresys.os._os_name = "haos" coresys.updater._data = {"ota": url} - url = coresys.hassos._get_download_url(AwesomeVersion(versionstr)) + url = coresys.os._get_download_url(AwesomeVersion(versionstr)) assert url == url_formatted diff --git a/tests/resolution/evaluation/test_evaluate_operating_system.py b/tests/resolution/evaluation/test_evaluate_operating_system.py index 7a0d01c85..d2234b80c 100644 --- a/tests/resolution/evaluation/test_evaluate_operating_system.py +++ b/tests/resolution/evaluation/test_evaluate_operating_system.py @@ -21,10 +21,10 @@ async def test_evaluation(coresys: CoreSys): await operating_system() assert operating_system.reason in coresys.resolution.unsupported - coresys.hassos._available = True + coresys.os._available = True await operating_system() assert operating_system.reason not in coresys.resolution.unsupported - coresys.hassos._available = False + coresys.os._available = False coresys.host._info = MagicMock(operating_system=SUPPORTED_OS[0]) await operating_system()