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

View File

@ -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),
]
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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],
],
)

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

View File

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

View File

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

View File

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

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
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()

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

View File

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