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
This commit is contained in:
Pascal Vizeli 2021-09-17 15:01:07 +02:00 committed by GitHub
parent f4c7f2cae1
commit 271e4f0cc4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 158 additions and 61 deletions

View File

@ -2,7 +2,7 @@
reports=no reports=no
jobs=2 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= extension-pkg-whitelist=
ciso8601 ciso8601

View File

@ -145,6 +145,7 @@ class RestAPI(CoreSysAttributes):
web.get("/os/info", api_os.info), web.get("/os/info", api_os.info),
web.post("/os/update", api_os.update), web.post("/os/update", api_os.update),
web.post("/os/config/sync", api_os.config_sync), web.post("/os/config/sync", api_os.config_sync),
web.post("/os/datadisk/move", api_os.migrate_data),
] ]
) )

View File

@ -4,3 +4,5 @@ ATTR_USE_RTC = "use_rtc"
ATTR_USE_NTP = "use_ntp" ATTR_USE_NTP = "use_ntp"
ATTR_DT_UTC = "dt_utc" ATTR_DT_UTC = "dt_utc"
ATTR_DT_SYNCHRONIZED = "dt_synchronized" ATTR_DT_SYNCHRONIZED = "dt_synchronized"
ATTR_DISK_DATA = "disk_data"
ATTR_DEVICE = "device"

View File

@ -36,7 +36,7 @@ class APIInfo(CoreSysAttributes):
return { return {
ATTR_SUPERVISOR: self.sys_supervisor.version, ATTR_SUPERVISOR: self.sys_supervisor.version,
ATTR_HOMEASSISTANT: self.sys_homeassistant.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_DOCKER: self.sys_docker.info.version,
ATTR_HOSTNAME: self.sys_host.info.hostname, ATTR_HOSTNAME: self.sys_host.info.hostname,
ATTR_OPERATING_SYSTEM: self.sys_host.info.operating_system, ATTR_OPERATING_SYSTEM: self.sys_host.info.operating_system,

View File

@ -1,6 +1,7 @@
"""Init file for Supervisor HassOS RESTful API.""" """Init file for Supervisor HassOS RESTful API."""
import asyncio import asyncio
import logging import logging
from pathlib import Path
from typing import Any, Awaitable, Dict from typing import Any, Awaitable, Dict
from aiohttp import web from aiohttp import web
@ -15,11 +16,13 @@ from ..const import (
) )
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..validate import version_tag from ..validate import version_tag
from .const import ATTR_DEVICE, ATTR_DISK_DATA
from .utils import api_process, api_validate from .utils import api_process, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): version_tag}) 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): class APIOS(CoreSysAttributes):
@ -29,22 +32,30 @@ class APIOS(CoreSysAttributes):
async def info(self, request: web.Request) -> Dict[str, Any]: async def info(self, request: web.Request) -> Dict[str, Any]:
"""Return OS information.""" """Return OS information."""
return { return {
ATTR_VERSION: self.sys_hassos.version, ATTR_VERSION: self.sys_os.version,
ATTR_VERSION_LATEST: self.sys_hassos.latest_version, ATTR_VERSION_LATEST: self.sys_os.latest_version,
ATTR_UPDATE_AVAILABLE: self.sys_hassos.need_update, ATTR_UPDATE_AVAILABLE: self.sys_os.need_update,
ATTR_BOARD: self.sys_hassos.board, ATTR_BOARD: self.sys_os.board,
ATTR_BOOT: self.sys_dbus.rauc.boot_slot, ATTR_BOOT: self.sys_dbus.rauc.boot_slot,
ATTR_DISK_DATA: self.sys_os.datadisk.disk_used,
} }
@api_process @api_process
async def update(self, request: web.Request) -> None: async def update(self, request: web.Request) -> None:
"""Update OS.""" """Update OS."""
body = await api_validate(SCHEMA_VERSION, request) 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 @api_process
def config_sync(self, request: web.Request) -> Awaitable[None]: def config_sync(self, request: web.Request) -> Awaitable[None]:
"""Trigger config reload on OS.""" """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]))

View File

@ -38,13 +38,13 @@ from .coresys import CoreSys
from .dbus.manager import DBusManager from .dbus.manager import DBusManager
from .discovery import Discovery from .discovery import Discovery
from .hardware.module import HardwareManager from .hardware.module import HardwareManager
from .hassos import HassOS
from .homeassistant.module import HomeAssistant from .homeassistant.module import HomeAssistant
from .host.manager import HostManager from .host.manager import HostManager
from .ingress import Ingress from .ingress import Ingress
from .misc.filter import filter_data from .misc.filter import filter_data
from .misc.scheduler import Scheduler from .misc.scheduler import Scheduler
from .misc.tasks import Tasks from .misc.tasks import Tasks
from .os.manager import OSManager
from .plugins.manager import PluginManager from .plugins.manager import PluginManager
from .resolution.module import ResolutionManager from .resolution.module import ResolutionManager
from .security import Security from .security import Security
@ -81,7 +81,7 @@ async def initialize_coresys() -> CoreSys:
coresys.store = StoreManager(coresys) coresys.store = StoreManager(coresys)
coresys.discovery = Discovery(coresys) coresys.discovery = Discovery(coresys)
coresys.dbus = DBusManager(coresys) coresys.dbus = DBusManager(coresys)
coresys.hassos = HassOS(coresys) coresys.os = OSManager(coresys)
coresys.scheduler = Scheduler(coresys) coresys.scheduler = Scheduler(coresys)
coresys.security = Security(coresys) coresys.security = Security(coresys)
coresys.bus = Bus(coresys) coresys.bus = Bus(coresys)

View File

@ -125,7 +125,7 @@ class Core(CoreSysAttributes):
# Load CPU/Arch # Load CPU/Arch
self.sys_arch.load(), self.sys_arch.load(),
# Load HassOS # Load HassOS
self.sys_hassos.load(), self.sys_os.load(),
# Load Stores # Load Stores
self.sys_store.load(), self.sys_store.load(),
# Load Add-ons # Load Add-ons
@ -169,7 +169,7 @@ class Core(CoreSysAttributes):
) )
# Mark booted partition as healthy # Mark booted partition as healthy
await self.sys_hassos.mark_healthy() await self.sys_os.mark_healthy()
# On release channel, try update itself # On release channel, try update itself
if self.sys_supervisor.need_update: if self.sys_supervisor.need_update:

View File

@ -26,7 +26,7 @@ if TYPE_CHECKING:
from .dbus.manager import DBusManager from .dbus.manager import DBusManager
from .discovery import Discovery from .discovery import Discovery
from .hardware.module import HardwareManager from .hardware.module import HardwareManager
from .hassos import HassOS from .os.manager import OSManager
from .homeassistant.module import HomeAssistant from .homeassistant.module import HomeAssistant
from .host.manager import HostManager from .host.manager import HostManager
from .ingress import Ingress from .ingress import Ingress
@ -79,7 +79,7 @@ class CoreSys:
self._host: Optional[HostManager] = None self._host: Optional[HostManager] = None
self._ingress: Optional[Ingress] = None self._ingress: Optional[Ingress] = None
self._dbus: Optional[DBusManager] = None self._dbus: Optional[DBusManager] = None
self._hassos: Optional[HassOS] = None self._os: Optional[OSManager] = None
self._services: Optional[ServiceManager] = None self._services: Optional[ServiceManager] = None
self._scheduler: Optional[Scheduler] = None self._scheduler: Optional[Scheduler] = None
self._store: Optional[StoreManager] = None self._store: Optional[StoreManager] = None
@ -411,18 +411,18 @@ class CoreSys:
self._ingress = value self._ingress = value
@property @property
def hassos(self) -> HassOS: def os(self) -> OSManager:
"""Return HassOS object.""" """Return OSManager object."""
if self._hassos is None: if self._os is None:
raise RuntimeError("HassOS not set!") raise RuntimeError("OSManager not set!")
return self._hassos return self._os
@hassos.setter @os.setter
def hassos(self, value: HassOS) -> None: def os(self, value: OSManager) -> None:
"""Set a HassOS object.""" """Set a OSManager object."""
if self._hassos: if self._os:
raise RuntimeError("HassOS already set!") raise RuntimeError("OSManager already set!")
self._hassos = value self._os = value
@property @property
def resolution(self) -> ResolutionManager: def resolution(self) -> ResolutionManager:
@ -650,9 +650,9 @@ class CoreSysAttributes:
return self.coresys.ingress return self.coresys.ingress
@property @property
def sys_hassos(self) -> HassOS: def sys_os(self) -> OSManager:
"""Return HassOS object.""" """Return OSManager object."""
return self.coresys.hassos return self.coresys.os
@property @property
def sys_resolution(self) -> ResolutionManager: def sys_resolution(self) -> ResolutionManager:

View File

@ -91,7 +91,7 @@ class HostManager(CoreSysAttributes):
if self.sys_dbus.agent.is_connected: if self.sys_dbus.agent.is_connected:
features.append(HostFeature.AGENT) features.append(HostFeature.AGENT)
if self.sys_hassos.available: if self.sys_os.available:
features.append(HostFeature.HAOS) features.append(HostFeature.HAOS)
return features return features

View File

@ -18,6 +18,7 @@ class JobCondition(str, Enum):
INTERNET_HOST = "internet_host" INTERNET_HOST = "internet_host"
RUNNING = "running" RUNNING = "running"
HAOS = "haos" HAOS = "haos"
OS_AGENT = "os_agent"
class JobExecutionLimit(str, Enum): class JobExecutionLimit(str, Enum):

View File

@ -10,6 +10,7 @@ import sentry_sdk
from ..const import CoreState from ..const import CoreState
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import HassioError, JobConditionException, JobException from ..exceptions import HassioError, JobConditionException, JobException
from ..host.const import HostFeature
from ..resolution.const import MINIMUM_FREE_SPACE_THRESHOLD, ContextType, IssueType from ..resolution.const import MINIMUM_FREE_SPACE_THRESHOLD, ContextType, IssueType
from .const import JobCondition, JobExecutionLimit from .const import JobCondition, JobExecutionLimit
@ -167,11 +168,19 @@ class Job(CoreSysAttributes):
f"'{self._method.__qualname__}' blocked from execution, no host internet connection" 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( raise JobConditionException(
f"'{self._method.__qualname__}' blocked from execution, no Home Assistant OS available" 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: async def _acquire_exection_limit(self) -> None:
"""Process exection limits.""" """Process exection limits."""
if self.limit not in ( if self.limit not in (

View File

@ -56,7 +56,7 @@ def filter_data(coresys: CoreSys, event: dict, hint: dict) -> dict:
}, },
"host": { "host": {
"arch": coresys.arch.default, "arch": coresys.arch.default,
"board": coresys.hassos.board, "board": coresys.os.board,
"deployment": coresys.host.info.deployment, "deployment": coresys.host.info.deployment,
"disk_free_space": coresys.host.info.free_space, "disk_free_space": coresys.host.info.free_space,
"host": coresys.host.info.operating_system, "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, "docker": coresys.docker.info.version,
"multicast": coresys.plugins.multicast.version, "multicast": coresys.plugins.multicast.version,
"observer": coresys.plugins.observer.version, "observer": coresys.plugins.observer.version,
"os": coresys.hassos.version, "os": coresys.os.version,
"supervisor": coresys.supervisor.version, "supervisor": coresys.supervisor.version,
}, },
"resolution": { "resolution": {
@ -87,7 +87,7 @@ def filter_data(coresys: CoreSys, event: dict, hint: dict) -> dict:
) )
event.setdefault("tags", []).extend( 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], ["machine", coresys.machine],
], ],
) )

View File

@ -0,0 +1 @@
"""Home Assistant Operating-System backend."""

View File

@ -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

View File

@ -1,4 +1,4 @@
"""HassOS support on supervisor.""" """OS support on supervisor."""
import asyncio import asyncio
import logging import logging
from pathlib import Path from pathlib import Path
@ -8,21 +8,23 @@ import aiohttp
from awesomeversion import AwesomeVersion, AwesomeVersionException from awesomeversion import AwesomeVersion, AwesomeVersionException
from cpe import CPE from cpe import CPE
from .coresys import CoreSys, CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
from .dbus.rauc import RaucState from ..dbus.rauc import RaucState
from .exceptions import DBusError, HassOSJobError, HassOSUpdateError from ..exceptions import DBusError, HassOSJobError, HassOSUpdateError
from .jobs.const import JobCondition, JobExecutionLimit from ..jobs.const import JobCondition, JobExecutionLimit
from .jobs.decorator import Job from ..jobs.decorator import Job
from .data_disk import DataDisk
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
class HassOS(CoreSysAttributes): class OSManager(CoreSysAttributes):
"""HassOS interface inside supervisor.""" """OS interface inside supervisor."""
def __init__(self, coresys: CoreSys): def __init__(self, coresys: CoreSys):
"""Initialize HassOS handler.""" """Initialize HassOS handler."""
self.coresys: CoreSys = coresys self.coresys: CoreSys = coresys
self._datadisk: DataDisk = DataDisk(coresys)
self._available: bool = False self._available: bool = False
self._version: Optional[AwesomeVersion] = None self._version: Optional[AwesomeVersion] = None
self._board: Optional[str] = None self._board: Optional[str] = None
@ -61,6 +63,11 @@ class HassOS(CoreSysAttributes):
"""Return OS name.""" """Return OS name."""
return self._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: def _get_download_url(self, version: AwesomeVersion) -> str:
raw_url = self.sys_updater.ota_url raw_url = self.sys_updater.ota_url
if raw_url is None: if raw_url is None:

View File

@ -34,6 +34,6 @@ class EvaluateOperatingSystem(EvaluateBase):
async def evaluate(self): async def evaluate(self):
"""Run evaluation.""" """Run evaluation."""
if self.sys_hassos.available: if self.sys_os.available:
return False return False
return self.sys_host.info.operating_system not in SUPPORTED_OS return self.sys_host.info.operating_system not in SUPPORTED_OS

View File

@ -241,10 +241,10 @@ class Updater(FileConfiguration, CoreSysAttributes):
) )
# Update HassOS version # Update HassOS version
if self.sys_hassos.board: if self.sys_os.board:
events.append("os") events.append("os")
self._data[ATTR_HASSOS] = AwesomeVersion( self._data[ATTR_HASSOS] = AwesomeVersion(
data["hassos"][self.sys_hassos.board] data["hassos"][self.sys_os.board]
) )
self._data[ATTR_OTA] = data["ota"] self._data[ATTR_OTA] = data["ota"]

View File

@ -25,9 +25,7 @@ class JSONEncoder(json.JSONEncoder):
if isinstance(o, set): if isinstance(o, set):
return list(o) return list(o)
if isinstance(o, Path): if isinstance(o, Path):
return str(o) return o.as_posix()
if hasattr(o, "as_dict"):
return o.as_dict()
return json.JSONEncoder.default(self, o) return json.JSONEncoder.default(self, o)

19
tests/api/test_os.py Normal file
View File

@ -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"]

View File

@ -119,10 +119,10 @@ async def test_haos(coresys: CoreSys):
return True return True
test = TestClass(coresys) test = TestClass(coresys)
coresys.hassos._available = True coresys.os._available = True
assert await test.execute() assert await test.execute()
coresys.hassos._available = False coresys.os._available = False
assert not await test.execute() assert not await test.execute()

1
tests/os/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Home Assistant Operating-System backend tests."""

View File

@ -11,8 +11,8 @@ from supervisor.coresys import CoreSys
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_ota_url_generic_x86_64_rename(coresys: CoreSys) -> None: async def test_ota_url_generic_x86_64_rename(coresys: CoreSys) -> None:
"""Test download URL generated.""" """Test download URL generated."""
coresys.hassos._board = "intel-nuc" coresys.os._board = "intel-nuc"
coresys.hassos._version = AwesomeVersion("5.13") coresys.os._version = AwesomeVersion("5.13")
await coresys.updater.fetch_data() await coresys.updater.fetch_data()
version6 = AwesomeVersion("6.0") 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" 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: 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 = "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) url_formatted = url.format(version=versionstr, board=board, os_name=os_name)
coresys.hassos._board = board coresys.os._board = board
coresys.hassos._os_name = os_name coresys.os._os_name = os_name
coresys.updater._data = {"ota": url} 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 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 = "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") url_formatted = url.format(version=versionstr, board=board, os_name="hassos")
coresys.hassos._board = board coresys.os._board = board
coresys.hassos._os_name = "haos" coresys.os._os_name = "haos"
coresys.updater._data = {"ota": url} 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 assert url == url_formatted

View File

@ -21,10 +21,10 @@ async def test_evaluation(coresys: CoreSys):
await operating_system() await operating_system()
assert operating_system.reason in coresys.resolution.unsupported assert operating_system.reason in coresys.resolution.unsupported
coresys.hassos._available = True coresys.os._available = True
await operating_system() await operating_system()
assert operating_system.reason not in coresys.resolution.unsupported 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]) coresys.host._info = MagicMock(operating_system=SUPPORTED_OS[0])
await operating_system() await operating_system()