mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-04-19 10:47:15 +00:00
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:
parent
f4c7f2cae1
commit
271e4f0cc4
2
pylintrc
2
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
|
||||
|
@ -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),
|
||||
]
|
||||
)
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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,
|
||||
|
@ -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]))
|
||||
|
@ -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)
|
||||
|
@ -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:
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -18,6 +18,7 @@ class JobCondition(str, Enum):
|
||||
INTERNET_HOST = "internet_host"
|
||||
RUNNING = "running"
|
||||
HAOS = "haos"
|
||||
OS_AGENT = "os_agent"
|
||||
|
||||
|
||||
class JobExecutionLimit(str, Enum):
|
||||
|
@ -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 (
|
||||
|
@ -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],
|
||||
],
|
||||
)
|
||||
|
1
supervisor/os/__init__.py
Normal file
1
supervisor/os/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Home Assistant Operating-System backend."""
|
47
supervisor/os/data_disk.py
Normal file
47
supervisor/os/data_disk.py
Normal 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
|
@ -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:
|
@ -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
|
||||
|
@ -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"]
|
||||
|
||||
|
@ -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
19
tests/api/test_os.py
Normal 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"]
|
@ -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
1
tests/os/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Home Assistant Operating-System backend tests."""
|
@ -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
|
@ -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()
|
||||
|
Loading…
x
Reference in New Issue
Block a user