mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-07-15 05:06:30 +00:00
Backup and restore track progress in job (#4503)
* Backup and restore track progress in job * Change to stage only updates and fix tests * Leave HA alone if it wasn't restored * skip check HA stage message when we don't check * Change to helper to get current job * Fix tests * Mark jobs as internal to skip notifying HA
This commit is contained in:
parent
de06361cb0
commit
f93b753c03
@ -158,8 +158,7 @@ class AddonManager(CoreSysAttributes):
|
|||||||
)
|
)
|
||||||
async def install(self, slug: str) -> None:
|
async def install(self, slug: str) -> None:
|
||||||
"""Install an add-on."""
|
"""Install an add-on."""
|
||||||
if job := self.sys_jobs.get_job():
|
self.sys_jobs.current.reference = slug
|
||||||
job.reference = slug
|
|
||||||
|
|
||||||
if slug in self.local:
|
if slug in self.local:
|
||||||
raise AddonsError(f"Add-on {slug} is already installed", _LOGGER.warning)
|
raise AddonsError(f"Add-on {slug} is already installed", _LOGGER.warning)
|
||||||
@ -263,8 +262,7 @@ class AddonManager(CoreSysAttributes):
|
|||||||
Returns a coroutine that completes when addon has state 'started' (see addon.start)
|
Returns a coroutine that completes when addon has state 'started' (see addon.start)
|
||||||
if addon is started after update. Else nothing is returned.
|
if addon is started after update. Else nothing is returned.
|
||||||
"""
|
"""
|
||||||
if job := self.sys_jobs.get_job():
|
self.sys_jobs.current.reference = slug
|
||||||
job.reference = slug
|
|
||||||
|
|
||||||
if slug not in self.local:
|
if slug not in self.local:
|
||||||
raise AddonsError(f"Add-on {slug} is not installed", _LOGGER.error)
|
raise AddonsError(f"Add-on {slug} is not installed", _LOGGER.error)
|
||||||
@ -329,8 +327,7 @@ class AddonManager(CoreSysAttributes):
|
|||||||
Returns a coroutine that completes when addon has state 'started' (see addon.start)
|
Returns a coroutine that completes when addon has state 'started' (see addon.start)
|
||||||
if addon is started after rebuild. Else nothing is returned.
|
if addon is started after rebuild. Else nothing is returned.
|
||||||
"""
|
"""
|
||||||
if job := self.sys_jobs.get_job():
|
self.sys_jobs.current.reference = slug
|
||||||
job.reference = slug
|
|
||||||
|
|
||||||
if slug not in self.local:
|
if slug not in self.local:
|
||||||
raise AddonsError(f"Add-on {slug} is not installed", _LOGGER.error)
|
raise AddonsError(f"Add-on {slug} is not installed", _LOGGER.error)
|
||||||
@ -387,8 +384,7 @@ class AddonManager(CoreSysAttributes):
|
|||||||
Returns a coroutine that completes when addon has state 'started' (see addon.start)
|
Returns a coroutine that completes when addon has state 'started' (see addon.start)
|
||||||
if addon is started after restore. Else nothing is returned.
|
if addon is started after restore. Else nothing is returned.
|
||||||
"""
|
"""
|
||||||
if job := self.sys_jobs.get_job():
|
self.sys_jobs.current.reference = slug
|
||||||
job.reference = slug
|
|
||||||
|
|
||||||
if slug not in self.local:
|
if slug not in self.local:
|
||||||
_LOGGER.debug("Add-on %s is not local available for restore", slug)
|
_LOGGER.debug("Add-on %s is not local available for restore", slug)
|
||||||
|
@ -1,11 +1,37 @@
|
|||||||
"""Backup consts."""
|
"""Backup consts."""
|
||||||
from enum import Enum
|
from enum import StrEnum
|
||||||
|
|
||||||
BUF_SIZE = 2**20 * 4 # 4MB
|
BUF_SIZE = 2**20 * 4 # 4MB
|
||||||
|
|
||||||
|
|
||||||
class BackupType(str, Enum):
|
class BackupType(StrEnum):
|
||||||
"""Backup type enum."""
|
"""Backup type enum."""
|
||||||
|
|
||||||
FULL = "full"
|
FULL = "full"
|
||||||
PARTIAL = "partial"
|
PARTIAL = "partial"
|
||||||
|
|
||||||
|
|
||||||
|
class BackupJobStage(StrEnum):
|
||||||
|
"""Backup job stage enum."""
|
||||||
|
|
||||||
|
ADDON_REPOSITORIES = "addon_repositories"
|
||||||
|
ADDONS = "addons"
|
||||||
|
DOCKER_CONFIG = "docker_config"
|
||||||
|
FINISHING_FILE = "finishing_file"
|
||||||
|
FOLDERS = "folders"
|
||||||
|
HOME_ASSISTANT = "home_assistant"
|
||||||
|
AWAIT_ADDON_RESTARTS = "await_addon_restarts"
|
||||||
|
|
||||||
|
|
||||||
|
class RestoreJobStage(StrEnum):
|
||||||
|
"""Restore job stage enum."""
|
||||||
|
|
||||||
|
ADDON_REPOSITORIES = "addon_repositories"
|
||||||
|
ADDONS = "addons"
|
||||||
|
AWAIT_ADDON_RESTARTS = "await_addon_restarts"
|
||||||
|
AWAIT_HOME_ASSISTANT_RESTART = "await_home_assistant_restart"
|
||||||
|
CHECK_HOME_ASSISTANT = "check_home_assistant"
|
||||||
|
DOCKER_CONFIG = "docker_config"
|
||||||
|
FOLDERS = "folders"
|
||||||
|
HOME_ASSISTANT = "home_assistant"
|
||||||
|
REMOVE_DELTA_ADDONS = "remove_delta_addons"
|
||||||
|
@ -23,7 +23,7 @@ from ..utils.dt import utcnow
|
|||||||
from ..utils.sentinel import DEFAULT
|
from ..utils.sentinel import DEFAULT
|
||||||
from ..utils.sentry import capture_exception
|
from ..utils.sentry import capture_exception
|
||||||
from .backup import Backup
|
from .backup import Backup
|
||||||
from .const import BackupType
|
from .const import BackupJobStage, BackupType, RestoreJobStage
|
||||||
from .utils import create_slug
|
from .utils import create_slug
|
||||||
from .validate import ALL_FOLDERS, SCHEMA_BACKUPS_CONFIG
|
from .validate import ALL_FOLDERS, SCHEMA_BACKUPS_CONFIG
|
||||||
|
|
||||||
@ -49,7 +49,7 @@ class BackupManager(FileConfiguration, CoreSysAttributes):
|
|||||||
"""Initialize a backup manager."""
|
"""Initialize a backup manager."""
|
||||||
super().__init__(FILE_HASSIO_BACKUPS, SCHEMA_BACKUPS_CONFIG)
|
super().__init__(FILE_HASSIO_BACKUPS, SCHEMA_BACKUPS_CONFIG)
|
||||||
self.coresys = coresys
|
self.coresys = coresys
|
||||||
self._backups = {}
|
self._backups: dict[str, Backup] = {}
|
||||||
self.lock = asyncio.Lock()
|
self.lock = asyncio.Lock()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -76,7 +76,7 @@ class BackupManager(FileConfiguration, CoreSysAttributes):
|
|||||||
if mount.state == UnitActiveState.ACTIVE
|
if mount.state == UnitActiveState.ACTIVE
|
||||||
]
|
]
|
||||||
|
|
||||||
def get(self, slug):
|
def get(self, slug: str) -> Backup:
|
||||||
"""Return backup object."""
|
"""Return backup object."""
|
||||||
return self._backups.get(slug)
|
return self._backups.get(slug)
|
||||||
|
|
||||||
@ -90,6 +90,24 @@ class BackupManager(FileConfiguration, CoreSysAttributes):
|
|||||||
|
|
||||||
return self.sys_config.path_backup
|
return self.sys_config.path_backup
|
||||||
|
|
||||||
|
def _change_stage(
|
||||||
|
self,
|
||||||
|
backup: Backup,
|
||||||
|
stage: BackupJobStage | RestoreJobStage,
|
||||||
|
restore: bool = False,
|
||||||
|
):
|
||||||
|
"""Change the stage of the current job during backup/restore.
|
||||||
|
|
||||||
|
Must be called from an existing backup/restore job.
|
||||||
|
"""
|
||||||
|
_LOGGER.info(
|
||||||
|
"%s %s starting stage %s",
|
||||||
|
"Restore" if restore else "Backup",
|
||||||
|
backup.slug,
|
||||||
|
stage,
|
||||||
|
)
|
||||||
|
self.sys_jobs.current.stage = stage
|
||||||
|
|
||||||
def _create_backup(
|
def _create_backup(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
@ -98,7 +116,10 @@ class BackupManager(FileConfiguration, CoreSysAttributes):
|
|||||||
compressed: bool = True,
|
compressed: bool = True,
|
||||||
location: Mount | type[DEFAULT] | None = DEFAULT,
|
location: Mount | type[DEFAULT] | None = DEFAULT,
|
||||||
) -> Backup:
|
) -> Backup:
|
||||||
"""Initialize a new backup object from name."""
|
"""Initialize a new backup object from name.
|
||||||
|
|
||||||
|
Must be called from an existing backup job.
|
||||||
|
"""
|
||||||
date_str = utcnow().isoformat()
|
date_str = utcnow().isoformat()
|
||||||
slug = create_slug(name, date_str)
|
slug = create_slug(name, date_str)
|
||||||
tar_file = Path(self._get_base_path(location), f"{slug}.tar")
|
tar_file = Path(self._get_base_path(location), f"{slug}.tar")
|
||||||
@ -107,23 +128,24 @@ class BackupManager(FileConfiguration, CoreSysAttributes):
|
|||||||
backup = Backup(self.coresys, tar_file)
|
backup = Backup(self.coresys, tar_file)
|
||||||
backup.new(slug, name, date_str, sys_type, password, compressed)
|
backup.new(slug, name, date_str, sys_type, password, compressed)
|
||||||
|
|
||||||
backup.store_repositories()
|
|
||||||
backup.store_dockerconfig()
|
|
||||||
|
|
||||||
# Add backup ID to job
|
# Add backup ID to job
|
||||||
if job := self.sys_jobs.get_job():
|
self.sys_jobs.current.reference = backup.slug
|
||||||
job.reference = backup.slug
|
|
||||||
|
self._change_stage(backup, BackupJobStage.ADDON_REPOSITORIES)
|
||||||
|
backup.store_repositories()
|
||||||
|
self._change_stage(backup, BackupJobStage.DOCKER_CONFIG)
|
||||||
|
backup.store_dockerconfig()
|
||||||
|
|
||||||
return backup
|
return backup
|
||||||
|
|
||||||
def load(self):
|
def load(self) -> Awaitable[None]:
|
||||||
"""Load exists backups data.
|
"""Load exists backups data.
|
||||||
|
|
||||||
Return a coroutine.
|
Return a coroutine.
|
||||||
"""
|
"""
|
||||||
return self.reload()
|
return self.reload()
|
||||||
|
|
||||||
async def reload(self):
|
async def reload(self) -> None:
|
||||||
"""Load exists backups."""
|
"""Load exists backups."""
|
||||||
self._backups = {}
|
self._backups = {}
|
||||||
|
|
||||||
@ -143,7 +165,7 @@ class BackupManager(FileConfiguration, CoreSysAttributes):
|
|||||||
if tasks:
|
if tasks:
|
||||||
await asyncio.wait(tasks)
|
await asyncio.wait(tasks)
|
||||||
|
|
||||||
def remove(self, backup):
|
def remove(self, backup: Backup) -> bool:
|
||||||
"""Remove a backup."""
|
"""Remove a backup."""
|
||||||
try:
|
try:
|
||||||
backup.tarfile.unlink()
|
backup.tarfile.unlink()
|
||||||
@ -156,7 +178,7 @@ class BackupManager(FileConfiguration, CoreSysAttributes):
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def import_backup(self, tar_file):
|
async def import_backup(self, tar_file: Path) -> Backup | None:
|
||||||
"""Check backup tarfile and import it."""
|
"""Check backup tarfile and import it."""
|
||||||
backup = Backup(self.coresys, tar_file)
|
backup = Backup(self.coresys, tar_file)
|
||||||
|
|
||||||
@ -193,26 +215,34 @@ class BackupManager(FileConfiguration, CoreSysAttributes):
|
|||||||
addon_list: list[Addon],
|
addon_list: list[Addon],
|
||||||
folder_list: list[str],
|
folder_list: list[str],
|
||||||
homeassistant: bool,
|
homeassistant: bool,
|
||||||
):
|
) -> Backup | None:
|
||||||
|
"""Create a backup.
|
||||||
|
|
||||||
|
Must be called from an existing backup job.
|
||||||
|
"""
|
||||||
addon_start_tasks: list[Awaitable[None]] | None = None
|
addon_start_tasks: list[Awaitable[None]] | None = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.sys_core.state = CoreState.FREEZE
|
self.sys_core.state = CoreState.FREEZE
|
||||||
|
|
||||||
async with backup:
|
async with backup:
|
||||||
# Backup add-ons
|
# Backup add-ons
|
||||||
if addon_list:
|
if addon_list:
|
||||||
_LOGGER.info("Backing up %s store Add-ons", backup.slug)
|
self._change_stage(backup, BackupJobStage.ADDONS)
|
||||||
addon_start_tasks = await backup.store_addons(addon_list)
|
addon_start_tasks = await backup.store_addons(addon_list)
|
||||||
|
|
||||||
# HomeAssistant Folder is for v1
|
# HomeAssistant Folder is for v1
|
||||||
if homeassistant:
|
if homeassistant:
|
||||||
|
self._change_stage(backup, BackupJobStage.HOME_ASSISTANT)
|
||||||
await backup.store_homeassistant()
|
await backup.store_homeassistant()
|
||||||
|
|
||||||
# Backup folders
|
# Backup folders
|
||||||
if folder_list:
|
if folder_list:
|
||||||
_LOGGER.info("Backing up %s store folders", backup.slug)
|
self._change_stage(backup, BackupJobStage.FOLDERS)
|
||||||
await backup.store_folders(folder_list)
|
await backup.store_folders(folder_list)
|
||||||
|
|
||||||
|
self._change_stage(backup, BackupJobStage.FINISHING_FILE)
|
||||||
|
|
||||||
except Exception as err: # pylint: disable=broad-except
|
except Exception as err: # pylint: disable=broad-except
|
||||||
_LOGGER.exception("Backup %s error", backup.slug)
|
_LOGGER.exception("Backup %s error", backup.slug)
|
||||||
capture_exception(err)
|
capture_exception(err)
|
||||||
@ -221,6 +251,7 @@ class BackupManager(FileConfiguration, CoreSysAttributes):
|
|||||||
self._backups[backup.slug] = backup
|
self._backups[backup.slug] = backup
|
||||||
|
|
||||||
if addon_start_tasks:
|
if addon_start_tasks:
|
||||||
|
self._change_stage(backup, BackupJobStage.AWAIT_ADDON_RESTARTS)
|
||||||
# Ignore exceptions from waiting for addon startup, addon errors handled elsewhere
|
# Ignore exceptions from waiting for addon startup, addon errors handled elsewhere
|
||||||
await asyncio.gather(*addon_start_tasks, return_exceptions=True)
|
await asyncio.gather(*addon_start_tasks, return_exceptions=True)
|
||||||
|
|
||||||
@ -234,11 +265,11 @@ class BackupManager(FileConfiguration, CoreSysAttributes):
|
|||||||
)
|
)
|
||||||
async def do_backup_full(
|
async def do_backup_full(
|
||||||
self,
|
self,
|
||||||
name="",
|
name: str = "",
|
||||||
password=None,
|
password: str | None = None,
|
||||||
compressed=True,
|
compressed: bool = True,
|
||||||
location: Mount | type[DEFAULT] | None = DEFAULT,
|
location: Mount | type[DEFAULT] | None = DEFAULT,
|
||||||
):
|
) -> Backup | None:
|
||||||
"""Create a full backup."""
|
"""Create a full backup."""
|
||||||
if self.lock.locked():
|
if self.lock.locked():
|
||||||
_LOGGER.error("A backup/restore process is already running")
|
_LOGGER.error("A backup/restore process is already running")
|
||||||
@ -270,7 +301,7 @@ class BackupManager(FileConfiguration, CoreSysAttributes):
|
|||||||
homeassistant: bool = False,
|
homeassistant: bool = False,
|
||||||
compressed: bool = True,
|
compressed: bool = True,
|
||||||
location: Mount | type[DEFAULT] | None = DEFAULT,
|
location: Mount | type[DEFAULT] | None = DEFAULT,
|
||||||
):
|
) -> Backup | None:
|
||||||
"""Create a partial backup."""
|
"""Create a partial backup."""
|
||||||
if self.lock.locked():
|
if self.lock.locked():
|
||||||
_LOGGER.error("A backup/restore process is already running")
|
_LOGGER.error("A backup/restore process is already running")
|
||||||
@ -315,28 +346,37 @@ class BackupManager(FileConfiguration, CoreSysAttributes):
|
|||||||
folder_list: list[str],
|
folder_list: list[str],
|
||||||
homeassistant: bool,
|
homeassistant: bool,
|
||||||
replace: bool,
|
replace: bool,
|
||||||
):
|
) -> bool:
|
||||||
|
"""Restore from a backup.
|
||||||
|
|
||||||
|
Must be called from an existing restore job.
|
||||||
|
"""
|
||||||
addon_start_tasks: list[Awaitable[None]] | None = None
|
addon_start_tasks: list[Awaitable[None]] | None = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
task_hass: asyncio.Task | None = None
|
task_hass: asyncio.Task | None = None
|
||||||
async with backup:
|
async with backup:
|
||||||
# Restore docker config
|
# Restore docker config
|
||||||
_LOGGER.info("Restoring %s Docker config", backup.slug)
|
self._change_stage(backup, RestoreJobStage.DOCKER_CONFIG, restore=True)
|
||||||
backup.restore_dockerconfig(replace)
|
backup.restore_dockerconfig(replace)
|
||||||
|
|
||||||
# Process folders
|
# Process folders
|
||||||
if folder_list:
|
if folder_list:
|
||||||
_LOGGER.info("Restoring %s folders", backup.slug)
|
self._change_stage(backup, RestoreJobStage.FOLDERS, restore=True)
|
||||||
await backup.restore_folders(folder_list)
|
await backup.restore_folders(folder_list)
|
||||||
|
|
||||||
# Process Home-Assistant
|
# Process Home-Assistant
|
||||||
if homeassistant:
|
if homeassistant:
|
||||||
_LOGGER.info("Restoring %s Home Assistant Core", backup.slug)
|
self._change_stage(
|
||||||
|
backup, RestoreJobStage.HOME_ASSISTANT, restore=True
|
||||||
|
)
|
||||||
task_hass = await backup.restore_homeassistant()
|
task_hass = await backup.restore_homeassistant()
|
||||||
|
|
||||||
# Delete delta add-ons
|
# Delete delta add-ons
|
||||||
if replace:
|
if replace:
|
||||||
_LOGGER.info("Removing Add-ons not in the backup %s", backup.slug)
|
self._change_stage(
|
||||||
|
backup, RestoreJobStage.REMOVE_DELTA_ADDONS, restore=True
|
||||||
|
)
|
||||||
for addon in self.sys_addons.installed:
|
for addon in self.sys_addons.installed:
|
||||||
if addon.slug in backup.addon_list:
|
if addon.slug in backup.addon_list:
|
||||||
continue
|
continue
|
||||||
@ -349,15 +389,21 @@ class BackupManager(FileConfiguration, CoreSysAttributes):
|
|||||||
_LOGGER.warning("Can't uninstall Add-on %s", addon.slug)
|
_LOGGER.warning("Can't uninstall Add-on %s", addon.slug)
|
||||||
|
|
||||||
if addon_list:
|
if addon_list:
|
||||||
_LOGGER.info("Restoring %s Repositories", backup.slug)
|
self._change_stage(
|
||||||
|
backup, RestoreJobStage.ADDON_REPOSITORIES, restore=True
|
||||||
|
)
|
||||||
await backup.restore_repositories(replace)
|
await backup.restore_repositories(replace)
|
||||||
|
|
||||||
_LOGGER.info("Restoring %s Add-ons", backup.slug)
|
self._change_stage(backup, RestoreJobStage.ADDONS, restore=True)
|
||||||
addon_start_tasks = await backup.restore_addons(addon_list)
|
addon_start_tasks = await backup.restore_addons(addon_list)
|
||||||
|
|
||||||
# Wait for Home Assistant Core update/downgrade
|
# Wait for Home Assistant Core update/downgrade
|
||||||
if task_hass:
|
if task_hass:
|
||||||
_LOGGER.info("Restore %s wait for Home-Assistant", backup.slug)
|
self._change_stage(
|
||||||
|
backup,
|
||||||
|
RestoreJobStage.AWAIT_HOME_ASSISTANT_RESTART,
|
||||||
|
restore=True,
|
||||||
|
)
|
||||||
await task_hass
|
await task_hass
|
||||||
|
|
||||||
except Exception as err: # pylint: disable=broad-except
|
except Exception as err: # pylint: disable=broad-except
|
||||||
@ -366,11 +412,20 @@ class BackupManager(FileConfiguration, CoreSysAttributes):
|
|||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
if addon_start_tasks:
|
if addon_start_tasks:
|
||||||
|
self._change_stage(
|
||||||
|
backup, RestoreJobStage.AWAIT_ADDON_RESTARTS, restore=True
|
||||||
|
)
|
||||||
# Ignore exceptions from waiting for addon startup, addon errors handled elsewhere
|
# Ignore exceptions from waiting for addon startup, addon errors handled elsewhere
|
||||||
await asyncio.gather(*addon_start_tasks, return_exceptions=True)
|
await asyncio.gather(*addon_start_tasks, return_exceptions=True)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
finally:
|
finally:
|
||||||
|
# Leave Home Assistant alone if it wasn't part of the restore
|
||||||
|
if homeassistant:
|
||||||
|
self._change_stage(
|
||||||
|
backup, RestoreJobStage.CHECK_HOME_ASSISTANT, restore=True
|
||||||
|
)
|
||||||
|
|
||||||
# Do we need start Home Assistant Core?
|
# Do we need start Home Assistant Core?
|
||||||
if not await self.sys_homeassistant.core.is_running():
|
if not await self.sys_homeassistant.core.is_running():
|
||||||
await self.sys_homeassistant.core.start()
|
await self.sys_homeassistant.core.start()
|
||||||
@ -390,11 +445,12 @@ class BackupManager(FileConfiguration, CoreSysAttributes):
|
|||||||
JobCondition.RUNNING,
|
JobCondition.RUNNING,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def do_restore_full(self, backup: Backup, password=None):
|
async def do_restore_full(
|
||||||
|
self, backup: Backup, password: str | None = None
|
||||||
|
) -> bool:
|
||||||
"""Restore a backup."""
|
"""Restore a backup."""
|
||||||
# Add backup ID to job
|
# Add backup ID to job
|
||||||
if job := self.sys_jobs.get_job():
|
self.sys_jobs.current.reference = backup.slug
|
||||||
job.reference = backup.slug
|
|
||||||
|
|
||||||
if self.lock.locked():
|
if self.lock.locked():
|
||||||
_LOGGER.error("A backup/restore process is already running")
|
_LOGGER.error("A backup/restore process is already running")
|
||||||
@ -431,6 +487,7 @@ class BackupManager(FileConfiguration, CoreSysAttributes):
|
|||||||
|
|
||||||
if success:
|
if success:
|
||||||
_LOGGER.info("Full-Restore %s done", backup.slug)
|
_LOGGER.info("Full-Restore %s done", backup.slug)
|
||||||
|
return success
|
||||||
|
|
||||||
@Job(
|
@Job(
|
||||||
name="backup_manager_partial_restore",
|
name="backup_manager_partial_restore",
|
||||||
@ -449,11 +506,10 @@ class BackupManager(FileConfiguration, CoreSysAttributes):
|
|||||||
addons: list[str] | None = None,
|
addons: list[str] | None = None,
|
||||||
folders: list[Path] | None = None,
|
folders: list[Path] | None = None,
|
||||||
password: str | None = None,
|
password: str | None = None,
|
||||||
):
|
) -> bool:
|
||||||
"""Restore a backup."""
|
"""Restore a backup."""
|
||||||
# Add backup ID to job
|
# Add backup ID to job
|
||||||
if job := self.sys_jobs.get_job():
|
self.sys_jobs.current.reference = backup.slug
|
||||||
job.reference = backup.slug
|
|
||||||
|
|
||||||
if self.lock.locked():
|
if self.lock.locked():
|
||||||
_LOGGER.error("A backup/restore process is already running")
|
_LOGGER.error("A backup/restore process is already running")
|
||||||
@ -495,3 +551,4 @@ class BackupManager(FileConfiguration, CoreSysAttributes):
|
|||||||
|
|
||||||
if success:
|
if success:
|
||||||
_LOGGER.info("Partial-Restore %s done", backup.slug)
|
_LOGGER.info("Partial-Restore %s done", backup.slug)
|
||||||
|
return success
|
||||||
|
@ -746,6 +746,7 @@ class DockerAddon(DockerInterface):
|
|||||||
name="docker_addon_hardware_events",
|
name="docker_addon_hardware_events",
|
||||||
conditions=[JobCondition.OS_AGENT],
|
conditions=[JobCondition.OS_AGENT],
|
||||||
limit=JobExecutionLimit.SINGLE_WAIT,
|
limit=JobExecutionLimit.SINGLE_WAIT,
|
||||||
|
internal=True,
|
||||||
)
|
)
|
||||||
async def _hardware_events(self, device: Device) -> None:
|
async def _hardware_events(self, device: Device) -> None:
|
||||||
"""Process Hardware events for adjust device access."""
|
"""Process Hardware events for adjust device access."""
|
||||||
|
@ -44,6 +44,10 @@ class JobNotFound(JobException):
|
|||||||
"""Exception for job not found."""
|
"""Exception for job not found."""
|
||||||
|
|
||||||
|
|
||||||
|
class JobInvalidUpdate(JobException):
|
||||||
|
"""Exception for invalid update to a job."""
|
||||||
|
|
||||||
|
|
||||||
class JobGroupExecutionLimitExceeded(JobException):
|
class JobGroupExecutionLimitExceeded(JobException):
|
||||||
"""Exception when job group execution limit exceeded."""
|
"""Exception when job group execution limit exceeded."""
|
||||||
|
|
||||||
|
@ -35,6 +35,7 @@ class HomeAssistantAPI(CoreSysAttributes):
|
|||||||
@Job(
|
@Job(
|
||||||
name="home_assistant_api_ensure_access_token",
|
name="home_assistant_api_ensure_access_token",
|
||||||
limit=JobExecutionLimit.SINGLE_WAIT,
|
limit=JobExecutionLimit.SINGLE_WAIT,
|
||||||
|
internal=True,
|
||||||
)
|
)
|
||||||
async def ensure_access_token(self) -> None:
|
async def ensure_access_token(self) -> None:
|
||||||
"""Ensure there is an access token."""
|
"""Ensure there is an access token."""
|
||||||
|
@ -35,5 +35,6 @@ class WSEvent(str, Enum):
|
|||||||
HEALTH_CHANGED = "health_changed"
|
HEALTH_CHANGED = "health_changed"
|
||||||
ISSUE_CHANGED = "issue_changed"
|
ISSUE_CHANGED = "issue_changed"
|
||||||
ISSUE_REMOVED = "issue_removed"
|
ISSUE_REMOVED = "issue_removed"
|
||||||
|
JOB = "job"
|
||||||
SUPERVISOR_UPDATE = "supervisor_update"
|
SUPERVISOR_UPDATE = "supervisor_update"
|
||||||
SUPPORTED_CHANGED = "supported_changed"
|
SUPPORTED_CHANGED = "supported_changed"
|
||||||
|
@ -439,6 +439,7 @@ class HomeAssistant(FileConfiguration, CoreSysAttributes):
|
|||||||
name="home_assistant_get_users",
|
name="home_assistant_get_users",
|
||||||
limit=JobExecutionLimit.THROTTLE_WAIT,
|
limit=JobExecutionLimit.THROTTLE_WAIT,
|
||||||
throttle_period=timedelta(minutes=5),
|
throttle_period=timedelta(minutes=5),
|
||||||
|
internal=True,
|
||||||
)
|
)
|
||||||
async def get_users(self) -> list[IngressSessionDataUser]:
|
async def get_users(self) -> list[IngressSessionDataUser]:
|
||||||
"""Get list of all configured users."""
|
"""Get list of all configured users."""
|
||||||
|
@ -44,6 +44,7 @@ class HomeAssistantSecrets(CoreSysAttributes):
|
|||||||
name="home_assistant_secrets_read",
|
name="home_assistant_secrets_read",
|
||||||
limit=JobExecutionLimit.THROTTLE_WAIT,
|
limit=JobExecutionLimit.THROTTLE_WAIT,
|
||||||
throttle_period=timedelta(seconds=60),
|
throttle_period=timedelta(seconds=60),
|
||||||
|
internal=True,
|
||||||
)
|
)
|
||||||
async def _read_secrets(self):
|
async def _read_secrets(self):
|
||||||
"""Read secrets.yaml into memory."""
|
"""Read secrets.yaml into memory."""
|
||||||
|
@ -107,7 +107,11 @@ class NetworkManager(CoreSysAttributes):
|
|||||||
|
|
||||||
return Interface.from_dbus_interface(self.sys_dbus.network.get(inet_name))
|
return Interface.from_dbus_interface(self.sys_dbus.network.get(inet_name))
|
||||||
|
|
||||||
@Job(name="network_manager_load", conditions=[JobCondition.HOST_NETWORK])
|
@Job(
|
||||||
|
name="network_manager_load",
|
||||||
|
conditions=[JobCondition.HOST_NETWORK],
|
||||||
|
internal=True,
|
||||||
|
)
|
||||||
async def load(self):
|
async def load(self):
|
||||||
"""Load network information and reapply defaults over dbus."""
|
"""Load network information and reapply defaults over dbus."""
|
||||||
# Apply current settings on each interface so OS can update any out of date defaults
|
# Apply current settings on each interface so OS can update any out of date defaults
|
||||||
|
@ -3,15 +3,18 @@ from collections.abc import Callable
|
|||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from contextvars import ContextVar, Token
|
from contextvars import ContextVar, Token
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from attrs import define, field
|
from attrs import Attribute, define, field
|
||||||
from attrs.setters import frozen
|
from attrs.setters import convert as attr_convert, frozen, validate as attr_validate
|
||||||
from attrs.validators import ge, le
|
from attrs.validators import ge, le
|
||||||
|
|
||||||
from ..coresys import CoreSys, CoreSysAttributes
|
from ..coresys import CoreSys, CoreSysAttributes
|
||||||
from ..exceptions import JobNotFound, JobStartException
|
from ..exceptions import JobNotFound, JobStartException
|
||||||
|
from ..homeassistant.const import WSEvent
|
||||||
from ..utils.common import FileConfiguration
|
from ..utils.common import FileConfiguration
|
||||||
|
from ..utils.sentry import capture_exception
|
||||||
from .const import ATTR_IGNORE_CONDITIONS, FILE_CONFIG_JOBS, JobCondition
|
from .const import ATTR_IGNORE_CONDITIONS, FILE_CONFIG_JOBS, JobCondition
|
||||||
from .validate import SCHEMA_JOBS_CONFIG
|
from .validate import SCHEMA_JOBS_CONFIG
|
||||||
|
|
||||||
@ -24,19 +27,57 @@ _CURRENT_JOB: ContextVar[UUID] = ContextVar("current_job")
|
|||||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _invalid_if_done(instance: "SupervisorJob", *_) -> None:
|
||||||
|
"""Validate that job is not done."""
|
||||||
|
if instance.done:
|
||||||
|
raise ValueError("Cannot update a job that is done")
|
||||||
|
|
||||||
|
|
||||||
|
def _on_change(instance: "SupervisorJob", attribute: Attribute, value: Any) -> Any:
|
||||||
|
"""Forward a change to a field on to the listener if defined."""
|
||||||
|
value = attr_convert(instance, attribute, value)
|
||||||
|
value = attr_validate(instance, attribute, value)
|
||||||
|
if instance.on_change:
|
||||||
|
instance.on_change(instance, attribute, value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
@define
|
@define
|
||||||
class SupervisorJob:
|
class SupervisorJob:
|
||||||
"""Representation of a job running in supervisor."""
|
"""Representation of a job running in supervisor."""
|
||||||
|
|
||||||
name: str = field(on_setattr=frozen)
|
name: str = field(on_setattr=frozen)
|
||||||
reference: str | None = None
|
reference: str | None = field(default=None, on_setattr=_on_change)
|
||||||
progress: int = field(default=0, validator=[ge(0), le(100)])
|
progress: float = field(
|
||||||
stage: str | None = None
|
default=0,
|
||||||
|
validator=[ge(0), le(100), _invalid_if_done],
|
||||||
|
on_setattr=_on_change,
|
||||||
|
converter=lambda val: round(val, 1),
|
||||||
|
)
|
||||||
|
stage: str | None = field(
|
||||||
|
default=None, validator=[_invalid_if_done], on_setattr=_on_change
|
||||||
|
)
|
||||||
uuid: UUID = field(init=False, factory=lambda: uuid4().hex, on_setattr=frozen)
|
uuid: UUID = field(init=False, factory=lambda: uuid4().hex, on_setattr=frozen)
|
||||||
parent_id: UUID = field(
|
parent_id: UUID | None = field(
|
||||||
init=False, factory=lambda: _CURRENT_JOB.get(None), on_setattr=frozen
|
init=False, factory=lambda: _CURRENT_JOB.get(None), on_setattr=frozen
|
||||||
)
|
)
|
||||||
done: bool = field(init=False, default=False)
|
done: bool | None = field(init=False, default=None, on_setattr=_on_change)
|
||||||
|
on_change: Callable[["SupervisorJob", Attribute, Any], None] | None = field(
|
||||||
|
default=None, on_setattr=frozen
|
||||||
|
)
|
||||||
|
internal: bool = field(default=False, on_setattr=frozen)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
"""Return dictionary representation."""
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"reference": self.reference,
|
||||||
|
"uuid": self.uuid,
|
||||||
|
"progress": self.progress,
|
||||||
|
"stage": self.stage,
|
||||||
|
"done": self.done,
|
||||||
|
"parent_id": self.parent_id,
|
||||||
|
}
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def start(self, *, on_done: Callable[["SupervisorJob"], None] | None = None):
|
def start(self, *, on_done: Callable[["SupervisorJob"], None] | None = None):
|
||||||
@ -46,11 +87,12 @@ class SupervisorJob:
|
|||||||
This is to ensure that each asyncio task can only be doing one job at a time as that
|
This is to ensure that each asyncio task can only be doing one job at a time as that
|
||||||
determines what resources it can and cannot access.
|
determines what resources it can and cannot access.
|
||||||
"""
|
"""
|
||||||
if self.done:
|
if self.done is not None:
|
||||||
raise JobStartException("Job is already complete")
|
raise JobStartException("Job has already been started")
|
||||||
if _CURRENT_JOB.get(None) != self.parent_id:
|
if _CURRENT_JOB.get(None) != self.parent_id:
|
||||||
raise JobStartException("Job has a different parent from current job")
|
raise JobStartException("Job has a different parent from current job")
|
||||||
|
|
||||||
|
self.done = False
|
||||||
token: Token[UUID] | None = None
|
token: Token[UUID] | None = None
|
||||||
try:
|
try:
|
||||||
token = _CURRENT_JOB.set(self.uuid)
|
token = _CURRENT_JOB.set(self.uuid)
|
||||||
@ -87,23 +129,54 @@ class JobManager(FileConfiguration, CoreSysAttributes):
|
|||||||
"""Set a list of ignored condition."""
|
"""Set a list of ignored condition."""
|
||||||
self._data[ATTR_IGNORE_CONDITIONS] = value
|
self._data[ATTR_IGNORE_CONDITIONS] = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current(self) -> SupervisorJob:
|
||||||
|
"""Return current job of the asyncio task.
|
||||||
|
|
||||||
|
Must be called from within a job. Raises RuntimeError if there is no current job.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return self.get_job(_CURRENT_JOB.get())
|
||||||
|
except (LookupError, JobNotFound) as err:
|
||||||
|
capture_exception(err)
|
||||||
|
raise RuntimeError("No job for the current asyncio task!") from None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_job(self) -> bool:
|
||||||
|
"""Return true if there is an active job for the current asyncio task."""
|
||||||
|
return bool(_CURRENT_JOB.get(None))
|
||||||
|
|
||||||
|
def _notify_on_job_change(
|
||||||
|
self, job: SupervisorJob, attribute: Attribute, value: Any
|
||||||
|
) -> None:
|
||||||
|
"""Notify Home Assistant of a change to a job."""
|
||||||
|
self.sys_homeassistant.websocket.supervisor_event(
|
||||||
|
WSEvent.JOB, job.as_dict() | {attribute.alias: value}
|
||||||
|
)
|
||||||
|
|
||||||
def new_job(
|
def new_job(
|
||||||
self, name: str, reference: str | None = None, initial_stage: str | None = None
|
self,
|
||||||
|
name: str,
|
||||||
|
reference: str | None = None,
|
||||||
|
initial_stage: str | None = None,
|
||||||
|
internal: bool = False,
|
||||||
) -> SupervisorJob:
|
) -> SupervisorJob:
|
||||||
"""Create a new job."""
|
"""Create a new job."""
|
||||||
job = SupervisorJob(name, reference=reference, stage=initial_stage)
|
job = SupervisorJob(
|
||||||
|
name,
|
||||||
|
reference=reference,
|
||||||
|
stage=initial_stage,
|
||||||
|
on_change=None if internal else self._notify_on_job_change,
|
||||||
|
internal=internal,
|
||||||
|
)
|
||||||
self._jobs[job.uuid] = job
|
self._jobs[job.uuid] = job
|
||||||
return job
|
return job
|
||||||
|
|
||||||
def get_job(self, uuid: UUID | None = None) -> SupervisorJob | None:
|
def get_job(self, uuid: UUID) -> SupervisorJob:
|
||||||
"""Return a job by uuid if it exists. Returns the current job of the asyncio task if uuid omitted."""
|
"""Return a job by uuid. Raises if it does not exist."""
|
||||||
if uuid:
|
if uuid not in self._jobs:
|
||||||
return self._jobs.get(uuid)
|
raise JobNotFound(f"No job found with id {uuid}")
|
||||||
|
return self._jobs[uuid]
|
||||||
if uuid := _CURRENT_JOB.get(None):
|
|
||||||
return self._jobs.get(uuid)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def remove_job(self, job: SupervisorJob) -> None:
|
def remove_job(self, job: SupervisorJob) -> None:
|
||||||
"""Remove a job by UUID."""
|
"""Remove a job by UUID."""
|
||||||
|
@ -38,6 +38,7 @@ class Job(CoreSysAttributes):
|
|||||||
| Callable[[CoreSys, datetime, list[datetime] | None], timedelta]
|
| Callable[[CoreSys, datetime, list[datetime] | None], timedelta]
|
||||||
| None = None,
|
| None = None,
|
||||||
throttle_max_calls: int | None = None,
|
throttle_max_calls: int | None = None,
|
||||||
|
internal: bool = False,
|
||||||
):
|
):
|
||||||
"""Initialize the Job class."""
|
"""Initialize the Job class."""
|
||||||
if name in _JOB_NAMES:
|
if name in _JOB_NAMES:
|
||||||
@ -55,6 +56,7 @@ class Job(CoreSysAttributes):
|
|||||||
self._method = None
|
self._method = None
|
||||||
self._last_call: dict[str | None, datetime] = {}
|
self._last_call: dict[str | None, datetime] = {}
|
||||||
self._rate_limited_calls: dict[str, list[datetime]] | None = None
|
self._rate_limited_calls: dict[str, list[datetime]] | None = None
|
||||||
|
self._internal = internal
|
||||||
|
|
||||||
# Validate Options
|
# Validate Options
|
||||||
if (
|
if (
|
||||||
@ -186,7 +188,9 @@ class Job(CoreSysAttributes):
|
|||||||
job_group = self._post_init(obj)
|
job_group = self._post_init(obj)
|
||||||
group_name: str | None = job_group.group_name if job_group else None
|
group_name: str | None = job_group.group_name if job_group else None
|
||||||
job = self.sys_jobs.new_job(
|
job = self.sys_jobs.new_job(
|
||||||
self.name, job_group.job_reference if job_group else None
|
self.name,
|
||||||
|
job_group.job_reference if job_group else None,
|
||||||
|
internal=self._internal,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Handle condition
|
# Handle condition
|
||||||
|
@ -42,8 +42,8 @@ class JobGroup(CoreSysAttributes):
|
|||||||
"""Return true if current task has the lock on this job group."""
|
"""Return true if current task has the lock on this job group."""
|
||||||
return (
|
return (
|
||||||
self.active_job
|
self.active_job
|
||||||
and (task_job := self.sys_jobs.get_job())
|
and self.sys_jobs.is_job
|
||||||
and self.active_job == task_job
|
and self.active_job == self.sys_jobs.current
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -188,8 +188,7 @@ class MountManager(FileConfiguration, CoreSysAttributes):
|
|||||||
async def create_mount(self, mount: Mount) -> None:
|
async def create_mount(self, mount: Mount) -> None:
|
||||||
"""Add/update a mount."""
|
"""Add/update a mount."""
|
||||||
# Add mount name to job
|
# Add mount name to job
|
||||||
if job := self.sys_jobs.get_job():
|
self.sys_jobs.current.reference = mount.name
|
||||||
job.reference = mount.name
|
|
||||||
|
|
||||||
if mount.name in self._mounts:
|
if mount.name in self._mounts:
|
||||||
_LOGGER.debug("Mount '%s' exists, unmounting then mounting from new config")
|
_LOGGER.debug("Mount '%s' exists, unmounting then mounting from new config")
|
||||||
@ -216,8 +215,7 @@ class MountManager(FileConfiguration, CoreSysAttributes):
|
|||||||
async def remove_mount(self, name: str, *, retain_entry: bool = False) -> None:
|
async def remove_mount(self, name: str, *, retain_entry: bool = False) -> None:
|
||||||
"""Remove a mount."""
|
"""Remove a mount."""
|
||||||
# Add mount name to job
|
# Add mount name to job
|
||||||
if job := self.sys_jobs.get_job():
|
self.sys_jobs.current.reference = name
|
||||||
job.reference = name
|
|
||||||
|
|
||||||
if name not in self._mounts:
|
if name not in self._mounts:
|
||||||
raise MountNotFound(
|
raise MountNotFound(
|
||||||
@ -247,8 +245,7 @@ class MountManager(FileConfiguration, CoreSysAttributes):
|
|||||||
async def reload_mount(self, name: str) -> None:
|
async def reload_mount(self, name: str) -> None:
|
||||||
"""Reload a mount to retry mounting with same config."""
|
"""Reload a mount to retry mounting with same config."""
|
||||||
# Add mount name to job
|
# Add mount name to job
|
||||||
if job := self.sys_jobs.get_job():
|
self.sys_jobs.current.reference = name
|
||||||
job.reference = name
|
|
||||||
|
|
||||||
if name not in self._mounts:
|
if name not in self._mounts:
|
||||||
raise MountNotFound(
|
raise MountNotFound(
|
||||||
|
@ -165,7 +165,7 @@ class DataDisk(CoreSysAttributes):
|
|||||||
if block.drive == drive.object_path
|
if block.drive == drive.object_path
|
||||||
]
|
]
|
||||||
|
|
||||||
@Job(name="data_disk_load", conditions=[JobCondition.OS_AGENT])
|
@Job(name="data_disk_load", conditions=[JobCondition.OS_AGENT], internal=True)
|
||||||
async def load(self) -> None:
|
async def load(self) -> None:
|
||||||
"""Load DataDisk feature."""
|
"""Load DataDisk feature."""
|
||||||
# Update datadisk details on OS-Agent
|
# Update datadisk details on OS-Agent
|
||||||
|
@ -227,7 +227,7 @@ class OSManager(CoreSysAttributes):
|
|||||||
)
|
)
|
||||||
raise HassOSUpdateError()
|
raise HassOSUpdateError()
|
||||||
|
|
||||||
@Job(name="os_manager_mark_healthy", conditions=[JobCondition.HAOS])
|
@Job(name="os_manager_mark_healthy", conditions=[JobCondition.HAOS], internal=True)
|
||||||
async def mark_healthy(self) -> None:
|
async def mark_healthy(self) -> None:
|
||||||
"""Set booted partition as good for rauc."""
|
"""Set booted partition as good for rauc."""
|
||||||
try:
|
try:
|
||||||
|
@ -202,6 +202,7 @@ async def test_watchdog_on_stop(coresys: CoreSys, install_addon_ssh: Addon) -> N
|
|||||||
|
|
||||||
async def test_listener_attached_on_install(coresys: CoreSys, repository):
|
async def test_listener_attached_on_install(coresys: CoreSys, repository):
|
||||||
"""Test events listener attached on addon install."""
|
"""Test events listener attached on addon install."""
|
||||||
|
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
||||||
container_collection = MagicMock()
|
container_collection = MagicMock()
|
||||||
container_collection.get.side_effect = DockerException()
|
container_collection.get.side_effect = DockerException()
|
||||||
with patch(
|
with patch(
|
||||||
@ -217,7 +218,7 @@ async def test_listener_attached_on_install(coresys: CoreSys, repository):
|
|||||||
"supervisor.addons.model.AddonModel.with_ingress",
|
"supervisor.addons.model.AddonModel.with_ingress",
|
||||||
new=PropertyMock(return_value=False),
|
new=PropertyMock(return_value=False),
|
||||||
):
|
):
|
||||||
await coresys.addons.install.__wrapped__(coresys.addons, TEST_ADDON_SLUG)
|
await coresys.addons.install(TEST_ADDON_SLUG)
|
||||||
|
|
||||||
_fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.RUNNING)
|
_fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.RUNNING)
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
"""Test ingress API."""
|
"""Test ingress API."""
|
||||||
# pylint: disable=protected-access
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
# pylint: disable=redefined-outer-name
|
from aiohttp.test_utils import TestClient
|
||||||
|
|
||||||
|
from supervisor.coresys import CoreSys
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
async def test_validate_session(api_client: TestClient, coresys: CoreSys):
|
||||||
async def test_validate_session(api_client, coresys):
|
|
||||||
"""Test validating ingress session."""
|
"""Test validating ingress session."""
|
||||||
with patch("aiohttp.web_request.BaseRequest.__getitem__", return_value=None):
|
with patch("aiohttp.web_request.BaseRequest.__getitem__", return_value=None):
|
||||||
resp = await api_client.post(
|
resp = await api_client.post(
|
||||||
@ -40,8 +39,9 @@ async def test_validate_session(api_client, coresys):
|
|||||||
assert coresys.ingress.sessions[session] > valid_time
|
assert coresys.ingress.sessions[session] > valid_time
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
async def test_validate_session_with_user_id(
|
||||||
async def test_validate_session_with_user_id(api_client, coresys):
|
api_client: TestClient, coresys: CoreSys, ha_ws_client: AsyncMock
|
||||||
|
):
|
||||||
"""Test validating ingress session with user ID passed."""
|
"""Test validating ingress session with user ID passed."""
|
||||||
with patch("aiohttp.web_request.BaseRequest.__getitem__", return_value=None):
|
with patch("aiohttp.web_request.BaseRequest.__getitem__", return_value=None):
|
||||||
resp = await api_client.post(
|
resp = await api_client.post(
|
||||||
@ -54,15 +54,16 @@ async def test_validate_session_with_user_id(api_client, coresys):
|
|||||||
"aiohttp.web_request.BaseRequest.__getitem__",
|
"aiohttp.web_request.BaseRequest.__getitem__",
|
||||||
return_value=coresys.homeassistant,
|
return_value=coresys.homeassistant,
|
||||||
):
|
):
|
||||||
client = coresys.homeassistant.websocket._client
|
ha_ws_client.async_send_command.return_value = [
|
||||||
client.async_send_command.return_value = [
|
|
||||||
{"id": "some-id", "name": "Some Name", "username": "sn"}
|
{"id": "some-id", "name": "Some Name", "username": "sn"}
|
||||||
]
|
]
|
||||||
|
|
||||||
resp = await api_client.post("/ingress/session", json={"user_id": "some-id"})
|
resp = await api_client.post("/ingress/session", json={"user_id": "some-id"})
|
||||||
result = await resp.json()
|
result = await resp.json()
|
||||||
|
|
||||||
client.async_send_command.assert_called_with({"type": "config/auth/list"})
|
assert {"type": "config/auth/list"} in [
|
||||||
|
call.args[0] for call in ha_ws_client.async_send_command.call_args_list
|
||||||
|
]
|
||||||
|
|
||||||
assert "session" in result["data"]
|
assert "session" in result["data"]
|
||||||
session = result["data"]["session"]
|
session = result["data"]["session"]
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from shutil import rmtree
|
from shutil import rmtree
|
||||||
from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch
|
from unittest.mock import ANY, AsyncMock, MagicMock, Mock, PropertyMock, patch
|
||||||
|
|
||||||
from awesomeversion import AwesomeVersion
|
from awesomeversion import AwesomeVersion
|
||||||
from dbus_fast import DBusError
|
from dbus_fast import DBusError
|
||||||
@ -21,6 +21,7 @@ from supervisor.docker.const import ContainerState
|
|||||||
from supervisor.docker.homeassistant import DockerHomeAssistant
|
from supervisor.docker.homeassistant import DockerHomeAssistant
|
||||||
from supervisor.docker.monitor import DockerContainerStateEvent
|
from supervisor.docker.monitor import DockerContainerStateEvent
|
||||||
from supervisor.exceptions import AddonsError, DockerError
|
from supervisor.exceptions import AddonsError, DockerError
|
||||||
|
from supervisor.homeassistant.core import HomeAssistantCore
|
||||||
from supervisor.homeassistant.module import HomeAssistant
|
from supervisor.homeassistant.module import HomeAssistant
|
||||||
from supervisor.mounts.mount import Mount
|
from supervisor.mounts.mount import Mount
|
||||||
|
|
||||||
@ -827,3 +828,312 @@ async def test_restore_with_healthcheck(
|
|||||||
assert state_changes == [AddonState.STOPPED, AddonState.STARTUP]
|
assert state_changes == [AddonState.STOPPED, AddonState.STARTUP]
|
||||||
assert install_addon_ssh.state == AddonState.STARTED
|
assert install_addon_ssh.state == AddonState.STARTED
|
||||||
assert coresys.core.state == CoreState.RUNNING
|
assert coresys.core.state == CoreState.RUNNING
|
||||||
|
|
||||||
|
|
||||||
|
def _make_backup_message_for_assert(
|
||||||
|
*,
|
||||||
|
full: bool = True,
|
||||||
|
restore: bool = False,
|
||||||
|
reference: str,
|
||||||
|
stage: str | None,
|
||||||
|
done: bool = False,
|
||||||
|
):
|
||||||
|
"""Make a backup message to use for assert test."""
|
||||||
|
return {
|
||||||
|
"type": "supervisor/event",
|
||||||
|
"data": {
|
||||||
|
"event": "job",
|
||||||
|
"data": {
|
||||||
|
"name": f"backup_manager_{'full' if full else 'partial'}_{'restore' if restore else 'backup'}",
|
||||||
|
"reference": reference,
|
||||||
|
"uuid": ANY,
|
||||||
|
"progress": 0,
|
||||||
|
"stage": stage,
|
||||||
|
"done": done,
|
||||||
|
"parent_id": None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_backup_progress(
|
||||||
|
coresys: CoreSys,
|
||||||
|
install_addon_ssh: Addon,
|
||||||
|
container: MagicMock,
|
||||||
|
ha_ws_client: AsyncMock,
|
||||||
|
tmp_supervisor_data,
|
||||||
|
path_extern,
|
||||||
|
):
|
||||||
|
"""Test progress is tracked during backups."""
|
||||||
|
container.status = "running"
|
||||||
|
install_addon_ssh.path_data.mkdir()
|
||||||
|
coresys.core.state = CoreState.RUNNING
|
||||||
|
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
AddonModel, "backup_mode", new=PropertyMock(return_value=AddonBackupMode.COLD)
|
||||||
|
), patch("supervisor.addons.addon.asyncio.Event.wait"):
|
||||||
|
full_backup: Backup = await coresys.backups.do_backup_full()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
call.args[0]
|
||||||
|
for call in ha_ws_client.async_send_command.call_args_list
|
||||||
|
if call.args[0]["data"].get("data", {}).get("name")
|
||||||
|
== "backup_manager_full_backup"
|
||||||
|
]
|
||||||
|
assert messages == [
|
||||||
|
_make_backup_message_for_assert(reference=None, stage=None),
|
||||||
|
_make_backup_message_for_assert(reference=full_backup.slug, stage=None),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
reference=full_backup.slug, stage="addon_repositories"
|
||||||
|
),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
reference=full_backup.slug, stage="docker_config"
|
||||||
|
),
|
||||||
|
_make_backup_message_for_assert(reference=full_backup.slug, stage="addons"),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
reference=full_backup.slug, stage="home_assistant"
|
||||||
|
),
|
||||||
|
_make_backup_message_for_assert(reference=full_backup.slug, stage="folders"),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
reference=full_backup.slug, stage="finishing_file"
|
||||||
|
),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
reference=full_backup.slug, stage="await_addon_restarts"
|
||||||
|
),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
reference=full_backup.slug, stage="await_addon_restarts", done=True
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
ha_ws_client.async_send_command.reset_mock()
|
||||||
|
partial_backup: Backup = await coresys.backups.do_backup_partial(
|
||||||
|
addons=["local_ssh"], folders=["media", "share", "ssl"]
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
call.args[0]
|
||||||
|
for call in ha_ws_client.async_send_command.call_args_list
|
||||||
|
if call.args[0]["data"].get("data", {}).get("name")
|
||||||
|
== "backup_manager_partial_backup"
|
||||||
|
]
|
||||||
|
assert messages == [
|
||||||
|
_make_backup_message_for_assert(full=False, reference=None, stage=None),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
full=False, reference=partial_backup.slug, stage=None
|
||||||
|
),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
full=False, reference=partial_backup.slug, stage="addon_repositories"
|
||||||
|
),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
full=False, reference=partial_backup.slug, stage="docker_config"
|
||||||
|
),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
full=False, reference=partial_backup.slug, stage="addons"
|
||||||
|
),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
full=False, reference=partial_backup.slug, stage="folders"
|
||||||
|
),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
full=False, reference=partial_backup.slug, stage="finishing_file"
|
||||||
|
),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
full=False,
|
||||||
|
reference=partial_backup.slug,
|
||||||
|
stage="finishing_file",
|
||||||
|
done=True,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_restore_progress(
|
||||||
|
request: pytest.FixtureRequest,
|
||||||
|
coresys: CoreSys,
|
||||||
|
install_addon_ssh: Addon,
|
||||||
|
container: MagicMock,
|
||||||
|
ha_ws_client: AsyncMock,
|
||||||
|
tmp_supervisor_data,
|
||||||
|
path_extern,
|
||||||
|
):
|
||||||
|
"""Test progress is tracked during backups."""
|
||||||
|
container.status = "running"
|
||||||
|
install_addon_ssh.path_data.mkdir()
|
||||||
|
install_addon_ssh.state = AddonState.STARTED
|
||||||
|
coresys.core.state = CoreState.RUNNING
|
||||||
|
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
||||||
|
|
||||||
|
full_backup: Backup = await coresys.backups.do_backup_full()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
ha_ws_client.async_send_command.reset_mock()
|
||||||
|
|
||||||
|
# Install another addon to be uninstalled
|
||||||
|
request.getfixturevalue("install_addon_example")
|
||||||
|
with patch("supervisor.addons.addon.asyncio.Event.wait"), patch.object(
|
||||||
|
HomeAssistant, "restore"
|
||||||
|
), patch.object(HomeAssistantCore, "update"), patch.object(
|
||||||
|
AddonModel, "_validate_availability"
|
||||||
|
), patch.object(
|
||||||
|
AddonModel, "with_ingress", new=PropertyMock(return_value=False)
|
||||||
|
):
|
||||||
|
await coresys.backups.do_restore_full(full_backup)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
call.args[0]
|
||||||
|
for call in ha_ws_client.async_send_command.call_args_list
|
||||||
|
if call.args[0]["data"].get("data", {}).get("name")
|
||||||
|
== "backup_manager_full_restore"
|
||||||
|
]
|
||||||
|
assert messages == [
|
||||||
|
_make_backup_message_for_assert(restore=True, reference=None, stage=None),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
restore=True, reference=full_backup.slug, stage=None
|
||||||
|
),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
restore=True, reference=full_backup.slug, stage="docker_config"
|
||||||
|
),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
restore=True, reference=full_backup.slug, stage="folders"
|
||||||
|
),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
restore=True,
|
||||||
|
reference=full_backup.slug,
|
||||||
|
stage="home_assistant",
|
||||||
|
),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
restore=True,
|
||||||
|
reference=full_backup.slug,
|
||||||
|
stage="remove_delta_addons",
|
||||||
|
),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
restore=True,
|
||||||
|
reference=full_backup.slug,
|
||||||
|
stage="addon_repositories",
|
||||||
|
),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
restore=True, reference=full_backup.slug, stage="addons"
|
||||||
|
),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
restore=True,
|
||||||
|
reference=full_backup.slug,
|
||||||
|
stage="await_home_assistant_restart",
|
||||||
|
),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
restore=True,
|
||||||
|
reference=full_backup.slug,
|
||||||
|
stage="await_addon_restarts",
|
||||||
|
),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
restore=True,
|
||||||
|
reference=full_backup.slug,
|
||||||
|
stage="check_home_assistant",
|
||||||
|
),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
restore=True,
|
||||||
|
reference=full_backup.slug,
|
||||||
|
stage="check_home_assistant",
|
||||||
|
done=True,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
folders_backup: Backup = await coresys.backups.do_backup_partial(
|
||||||
|
folders=["media", "share", "ssl"]
|
||||||
|
)
|
||||||
|
ha_ws_client.async_send_command.reset_mock()
|
||||||
|
await coresys.backups.do_restore_partial(
|
||||||
|
folders_backup, folders=["media", "share", "ssl"]
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
call.args[0]
|
||||||
|
for call in ha_ws_client.async_send_command.call_args_list
|
||||||
|
if call.args[0]["data"].get("data", {}).get("name")
|
||||||
|
== "backup_manager_partial_restore"
|
||||||
|
]
|
||||||
|
assert messages == [
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
full=False, restore=True, reference=None, stage=None
|
||||||
|
),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
full=False,
|
||||||
|
restore=True,
|
||||||
|
reference=folders_backup.slug,
|
||||||
|
stage=None,
|
||||||
|
),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
full=False,
|
||||||
|
restore=True,
|
||||||
|
reference=folders_backup.slug,
|
||||||
|
stage="docker_config",
|
||||||
|
),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
full=False,
|
||||||
|
restore=True,
|
||||||
|
reference=folders_backup.slug,
|
||||||
|
stage="folders",
|
||||||
|
),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
full=False,
|
||||||
|
restore=True,
|
||||||
|
reference=folders_backup.slug,
|
||||||
|
stage="folders",
|
||||||
|
done=True,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
container.status = "stopped"
|
||||||
|
install_addon_ssh.state = AddonState.STOPPED
|
||||||
|
addon_backup: Backup = await coresys.backups.do_backup_partial(addons=["local_ssh"])
|
||||||
|
|
||||||
|
ha_ws_client.async_send_command.reset_mock()
|
||||||
|
with patch.object(AddonModel, "_validate_availability"), patch.object(
|
||||||
|
HomeAssistantCore, "start"
|
||||||
|
):
|
||||||
|
await coresys.backups.do_restore_partial(addon_backup, addons=["local_ssh"])
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
call.args[0]
|
||||||
|
for call in ha_ws_client.async_send_command.call_args_list
|
||||||
|
if call.args[0]["data"].get("data", {}).get("name")
|
||||||
|
== "backup_manager_partial_restore"
|
||||||
|
]
|
||||||
|
assert messages == [
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
full=False, restore=True, reference=None, stage=None
|
||||||
|
),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
full=False,
|
||||||
|
restore=True,
|
||||||
|
reference=addon_backup.slug,
|
||||||
|
stage=None,
|
||||||
|
),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
full=False,
|
||||||
|
restore=True,
|
||||||
|
reference=addon_backup.slug,
|
||||||
|
stage="docker_config",
|
||||||
|
),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
full=False,
|
||||||
|
restore=True,
|
||||||
|
reference=addon_backup.slug,
|
||||||
|
stage="addon_repositories",
|
||||||
|
),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
full=False,
|
||||||
|
restore=True,
|
||||||
|
reference=addon_backup.slug,
|
||||||
|
stage="addons",
|
||||||
|
),
|
||||||
|
_make_backup_message_for_assert(
|
||||||
|
full=False,
|
||||||
|
restore=True,
|
||||||
|
reference=addon_backup.slug,
|
||||||
|
stage="addons",
|
||||||
|
done=True,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
@ -371,6 +371,12 @@ async def coresys(
|
|||||||
await coresys_obj.websession.close()
|
await coresys_obj.websession.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ha_ws_client(coresys: CoreSys) -> AsyncMock:
|
||||||
|
"""Return HA WS client mock for assertions."""
|
||||||
|
return coresys.homeassistant.websocket._client
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def tmp_supervisor_data(coresys: CoreSys, tmp_path: Path) -> Path:
|
async def tmp_supervisor_data(coresys: CoreSys, tmp_path: Path) -> Path:
|
||||||
"""Patch supervisor data to be tmp_path."""
|
"""Patch supervisor data to be tmp_path."""
|
||||||
@ -516,6 +522,18 @@ def install_addon_ssh(coresys: CoreSys, repository):
|
|||||||
yield addon
|
yield addon
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def install_addon_example(coresys: CoreSys, repository):
|
||||||
|
"""Install local_example add-on."""
|
||||||
|
store = coresys.addons.store["local_example"]
|
||||||
|
coresys.addons.data.install(store)
|
||||||
|
coresys.addons.data._data = coresys.addons.data._schema(coresys.addons.data._data)
|
||||||
|
|
||||||
|
addon = Addon(coresys, store.slug)
|
||||||
|
coresys.addons.local[addon.slug] = addon
|
||||||
|
yield addon
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def mock_full_backup(coresys: CoreSys, tmp_path) -> Backup:
|
async def mock_full_backup(coresys: CoreSys, tmp_path) -> Backup:
|
||||||
"""Mock a full backup."""
|
"""Mock a full backup."""
|
||||||
|
0
tests/fixtures/addons/local/example/Dockerfile.aarch64
vendored
Normal file
0
tests/fixtures/addons/local/example/Dockerfile.aarch64
vendored
Normal file
14
tests/fixtures/addons/local/example/build.yaml
vendored
Normal file
14
tests/fixtures/addons/local/example/build.yaml
vendored
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# https://developers.home-assistant.io/docs/add-ons/configuration#add-on-dockerfile
|
||||||
|
build_from:
|
||||||
|
aarch64: "ghcr.io/home-assistant/aarch64-base:3.15"
|
||||||
|
amd64: "ghcr.io/home-assistant/amd64-base:3.15"
|
||||||
|
armhf: "ghcr.io/home-assistant/armhf-base:3.15"
|
||||||
|
armv7: "ghcr.io/home-assistant/armv7-base:3.15"
|
||||||
|
i386: "ghcr.io/home-assistant/i386-base:3.15"
|
||||||
|
labels:
|
||||||
|
org.opencontainers.image.title: "Home Assistant Add-on: Example add-on"
|
||||||
|
org.opencontainers.image.description: "Example add-on to use as a blueprint for new add-ons."
|
||||||
|
org.opencontainers.image.source: "https://github.com/home-assistant/addons-example"
|
||||||
|
org.opencontainers.image.licenses: "Apache License 2.0"
|
||||||
|
args:
|
||||||
|
TEMPIO_VERSION: "2021.09.0"
|
19
tests/fixtures/addons/local/example/config.yaml
vendored
Normal file
19
tests/fixtures/addons/local/example/config.yaml
vendored
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# https://developers.home-assistant.io/docs/add-ons/configuration#add-on-config
|
||||||
|
name: Example add-on
|
||||||
|
version: "1.2.0"
|
||||||
|
slug: example
|
||||||
|
description: Example add-on
|
||||||
|
url: "https://github.com/home-assistant/addons-example/tree/main/example"
|
||||||
|
arch:
|
||||||
|
- armhf
|
||||||
|
- armv7
|
||||||
|
- aarch64
|
||||||
|
- amd64
|
||||||
|
- i386
|
||||||
|
init: false
|
||||||
|
map:
|
||||||
|
- share:rw
|
||||||
|
options:
|
||||||
|
message: "Hello world..."
|
||||||
|
schema:
|
||||||
|
message: "str?"
|
@ -1,7 +1,7 @@
|
|||||||
"""Test network manager."""
|
"""Test network manager."""
|
||||||
import asyncio
|
import asyncio
|
||||||
from ipaddress import IPv4Address, IPv6Address
|
from ipaddress import IPv4Address, IPv6Address
|
||||||
from unittest.mock import patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
from dbus_fast import Variant
|
from dbus_fast import Variant
|
||||||
import pytest
|
import pytest
|
||||||
@ -159,13 +159,11 @@ async def test_scan_wifi_with_failures(
|
|||||||
|
|
||||||
|
|
||||||
async def test_host_connectivity_changed(
|
async def test_host_connectivity_changed(
|
||||||
coresys: CoreSys, network_manager_service: NetworkManagerService
|
coresys: CoreSys,
|
||||||
|
network_manager_service: NetworkManagerService,
|
||||||
|
ha_ws_client: AsyncMock,
|
||||||
):
|
):
|
||||||
"""Test host connectivity changed."""
|
"""Test host connectivity changed."""
|
||||||
# pylint: disable=protected-access
|
|
||||||
client = coresys.homeassistant.websocket._client
|
|
||||||
# pylint: enable=protected-access
|
|
||||||
|
|
||||||
await coresys.host.load()
|
await coresys.host.load()
|
||||||
assert coresys.host.network.connectivity is True
|
assert coresys.host.network.connectivity is True
|
||||||
|
|
||||||
@ -173,48 +171,42 @@ async def test_host_connectivity_changed(
|
|||||||
await network_manager_service.ping()
|
await network_manager_service.ping()
|
||||||
assert coresys.host.network.connectivity is False
|
assert coresys.host.network.connectivity is False
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
client.async_send_command.assert_called_once_with(
|
assert {
|
||||||
{
|
|
||||||
"type": WSType.SUPERVISOR_EVENT,
|
"type": WSType.SUPERVISOR_EVENT,
|
||||||
"data": {
|
"data": {
|
||||||
"event": WSEvent.SUPERVISOR_UPDATE,
|
"event": WSEvent.SUPERVISOR_UPDATE,
|
||||||
"update_key": "network",
|
"update_key": "network",
|
||||||
"data": {"host_internet": False},
|
"data": {"host_internet": False},
|
||||||
},
|
},
|
||||||
}
|
} in [call.args[0] for call in ha_ws_client.async_send_command.call_args_list]
|
||||||
)
|
|
||||||
|
|
||||||
client.async_send_command.reset_mock()
|
ha_ws_client.async_send_command.reset_mock()
|
||||||
network_manager_service.emit_properties_changed({}, ["Connectivity"])
|
network_manager_service.emit_properties_changed({}, ["Connectivity"])
|
||||||
await network_manager_service.ping()
|
await network_manager_service.ping()
|
||||||
await network_manager_service.ping()
|
await network_manager_service.ping()
|
||||||
assert coresys.host.network.connectivity is True
|
assert coresys.host.network.connectivity is True
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
client.async_send_command.assert_called_once_with(
|
assert {
|
||||||
{
|
|
||||||
"type": WSType.SUPERVISOR_EVENT,
|
"type": WSType.SUPERVISOR_EVENT,
|
||||||
"data": {
|
"data": {
|
||||||
"event": WSEvent.SUPERVISOR_UPDATE,
|
"event": WSEvent.SUPERVISOR_UPDATE,
|
||||||
"update_key": "network",
|
"update_key": "network",
|
||||||
"data": {"host_internet": True},
|
"data": {"host_internet": True},
|
||||||
},
|
},
|
||||||
}
|
} in [call.args[0] for call in ha_ws_client.async_send_command.call_args_list]
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_host_connectivity_disabled(
|
async def test_host_connectivity_disabled(
|
||||||
coresys: CoreSys, network_manager_service: NetworkManagerService
|
coresys: CoreSys,
|
||||||
|
network_manager_service: NetworkManagerService,
|
||||||
|
ha_ws_client: AsyncMock,
|
||||||
):
|
):
|
||||||
"""Test host connectivity check disabled."""
|
"""Test host connectivity check disabled."""
|
||||||
# pylint: disable=protected-access
|
|
||||||
client = coresys.homeassistant.websocket._client
|
|
||||||
# pylint: enable=protected-access
|
|
||||||
|
|
||||||
await coresys.host.network.load()
|
await coresys.host.network.load()
|
||||||
|
|
||||||
coresys.core.state = CoreState.RUNNING
|
coresys.core.state = CoreState.RUNNING
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
client.async_send_command.reset_mock()
|
ha_ws_client.async_send_command.reset_mock()
|
||||||
|
|
||||||
assert "connectivity_check" not in coresys.resolution.unsupported
|
assert "connectivity_check" not in coresys.resolution.unsupported
|
||||||
assert coresys.host.network.connectivity is True
|
assert coresys.host.network.connectivity is True
|
||||||
@ -223,7 +215,7 @@ async def test_host_connectivity_disabled(
|
|||||||
await network_manager_service.ping()
|
await network_manager_service.ping()
|
||||||
assert coresys.host.network.connectivity is None
|
assert coresys.host.network.connectivity is None
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
client.async_send_command.assert_any_call(
|
ha_ws_client.async_send_command.assert_any_call(
|
||||||
{
|
{
|
||||||
"type": WSType.SUPERVISOR_EVENT,
|
"type": WSType.SUPERVISOR_EVENT,
|
||||||
"data": {
|
"data": {
|
||||||
@ -235,13 +227,13 @@ async def test_host_connectivity_disabled(
|
|||||||
)
|
)
|
||||||
assert "connectivity_check" in coresys.resolution.unsupported
|
assert "connectivity_check" in coresys.resolution.unsupported
|
||||||
|
|
||||||
client.async_send_command.reset_mock()
|
ha_ws_client.async_send_command.reset_mock()
|
||||||
network_manager_service.emit_properties_changed({"ConnectivityCheckEnabled": True})
|
network_manager_service.emit_properties_changed({"ConnectivityCheckEnabled": True})
|
||||||
await network_manager_service.ping()
|
await network_manager_service.ping()
|
||||||
await network_manager_service.ping()
|
await network_manager_service.ping()
|
||||||
assert coresys.host.network.connectivity is True
|
assert coresys.host.network.connectivity is True
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
client.async_send_command.assert_any_call(
|
ha_ws_client.async_send_command.assert_any_call(
|
||||||
{
|
{
|
||||||
"type": WSType.SUPERVISOR_EVENT,
|
"type": WSType.SUPERVISOR_EVENT,
|
||||||
"data": {
|
"data": {
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
# pylint: disable=protected-access,import-error
|
# pylint: disable=protected-access,import-error
|
||||||
import asyncio
|
import asyncio
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from unittest.mock import AsyncMock, Mock, PropertyMock, patch
|
from unittest.mock import ANY, AsyncMock, Mock, PropertyMock, patch
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from aiohttp.client_exceptions import ClientError
|
from aiohttp.client_exceptions import ClientError
|
||||||
@ -736,7 +736,7 @@ async def test_job_cleanup(coresys: CoreSys, loop: asyncio.BaseEventLoop):
|
|||||||
@Job(name="test_job_cleanup_execute", limit=JobExecutionLimit.ONCE)
|
@Job(name="test_job_cleanup_execute", limit=JobExecutionLimit.ONCE)
|
||||||
async def execute(self):
|
async def execute(self):
|
||||||
"""Execute the class method."""
|
"""Execute the class method."""
|
||||||
self.job = coresys.jobs.get_job()
|
self.job = coresys.jobs.current
|
||||||
await self.event.wait()
|
await self.event.wait()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@ -773,7 +773,7 @@ async def test_job_skip_cleanup(coresys: CoreSys, loop: asyncio.BaseEventLoop):
|
|||||||
)
|
)
|
||||||
async def execute(self):
|
async def execute(self):
|
||||||
"""Execute the class method."""
|
"""Execute the class method."""
|
||||||
self.job = coresys.jobs.get_job()
|
self.job = coresys.jobs.current
|
||||||
await self.event.wait()
|
await self.event.wait()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@ -940,3 +940,53 @@ async def test_execution_limit_group_throttle_rate_limit(
|
|||||||
|
|
||||||
assert test1.call == 3
|
assert test1.call == 3
|
||||||
assert test2.call == 3
|
assert test2.call == 3
|
||||||
|
|
||||||
|
|
||||||
|
async def test_internal_jobs_no_notify(coresys: CoreSys):
|
||||||
|
"""Test internal jobs do not send any notifications."""
|
||||||
|
|
||||||
|
class TestClass:
|
||||||
|
"""Test class."""
|
||||||
|
|
||||||
|
def __init__(self, coresys: CoreSys):
|
||||||
|
"""Initialize the test class."""
|
||||||
|
self.coresys = coresys
|
||||||
|
|
||||||
|
@Job(name="test_internal_jobs_no_notify_internal", internal=True)
|
||||||
|
async def execute_internal(self) -> bool:
|
||||||
|
"""Execute the class method."""
|
||||||
|
return True
|
||||||
|
|
||||||
|
@Job(name="test_internal_jobs_no_notify_default")
|
||||||
|
async def execute_default(self) -> bool:
|
||||||
|
"""Execute the class method."""
|
||||||
|
return True
|
||||||
|
|
||||||
|
test1 = TestClass(coresys)
|
||||||
|
client = coresys.homeassistant.websocket._client
|
||||||
|
client.async_send_command.reset_mock()
|
||||||
|
|
||||||
|
await test1.execute_internal()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
client.async_send_command.assert_not_called()
|
||||||
|
|
||||||
|
await test1.execute_default()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
client.async_send_command.call_count == 2
|
||||||
|
client.async_send_command.assert_called_with(
|
||||||
|
{
|
||||||
|
"type": "supervisor/event",
|
||||||
|
"data": {
|
||||||
|
"event": "job",
|
||||||
|
"data": {
|
||||||
|
"name": "test_internal_jobs_no_notify_default",
|
||||||
|
"reference": None,
|
||||||
|
"uuid": ANY,
|
||||||
|
"progress": 0,
|
||||||
|
"stage": None,
|
||||||
|
"done": True,
|
||||||
|
"parent_id": None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
"""Test the condition decorators."""
|
"""Test the condition decorators."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import ANY
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
# pylint: disable=protected-access,import-error
|
# pylint: disable=protected-access,import-error
|
||||||
@ -31,13 +34,14 @@ async def test_job_done(coresys: CoreSys):
|
|||||||
"""Test done set correctly with jobs."""
|
"""Test done set correctly with jobs."""
|
||||||
job = coresys.jobs.new_job(TEST_JOB)
|
job = coresys.jobs.new_job(TEST_JOB)
|
||||||
assert not job.done
|
assert not job.done
|
||||||
assert coresys.jobs.get_job() != job
|
assert not coresys.jobs.is_job
|
||||||
|
|
||||||
with job.start():
|
with job.start():
|
||||||
assert coresys.jobs.get_job() == job
|
assert coresys.jobs.is_job
|
||||||
|
assert coresys.jobs.current == job
|
||||||
assert not job.done
|
assert not job.done
|
||||||
|
|
||||||
assert coresys.jobs.get_job() != job
|
assert not coresys.jobs.is_job
|
||||||
assert job.done
|
assert job.done
|
||||||
|
|
||||||
with pytest.raises(JobStartException):
|
with pytest.raises(JobStartException):
|
||||||
@ -56,7 +60,7 @@ async def test_job_start_bad_parent(coresys: CoreSys):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
with job2.start():
|
with job2.start():
|
||||||
assert coresys.jobs.get_job() == job2
|
assert coresys.jobs.current == job2
|
||||||
|
|
||||||
|
|
||||||
async def test_update_job(coresys: CoreSys):
|
async def test_update_job(coresys: CoreSys):
|
||||||
@ -74,3 +78,107 @@ async def test_update_job(coresys: CoreSys):
|
|||||||
|
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
job.progress = -10
|
job.progress = -10
|
||||||
|
|
||||||
|
|
||||||
|
async def test_notify_on_change(coresys: CoreSys):
|
||||||
|
"""Test jobs notify Home Assistant on changes."""
|
||||||
|
job = coresys.jobs.new_job(TEST_JOB)
|
||||||
|
|
||||||
|
job.progress = 50
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
coresys.homeassistant.websocket._client.async_send_command.assert_called_with(
|
||||||
|
{
|
||||||
|
"type": "supervisor/event",
|
||||||
|
"data": {
|
||||||
|
"event": "job",
|
||||||
|
"data": {
|
||||||
|
"name": TEST_JOB,
|
||||||
|
"reference": None,
|
||||||
|
"uuid": ANY,
|
||||||
|
"progress": 50,
|
||||||
|
"stage": None,
|
||||||
|
"done": None,
|
||||||
|
"parent_id": None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
job.stage = "test"
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
coresys.homeassistant.websocket._client.async_send_command.assert_called_with(
|
||||||
|
{
|
||||||
|
"type": "supervisor/event",
|
||||||
|
"data": {
|
||||||
|
"event": "job",
|
||||||
|
"data": {
|
||||||
|
"name": TEST_JOB,
|
||||||
|
"reference": None,
|
||||||
|
"uuid": ANY,
|
||||||
|
"progress": 50,
|
||||||
|
"stage": "test",
|
||||||
|
"done": None,
|
||||||
|
"parent_id": None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
job.reference = "test"
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
coresys.homeassistant.websocket._client.async_send_command.assert_called_with(
|
||||||
|
{
|
||||||
|
"type": "supervisor/event",
|
||||||
|
"data": {
|
||||||
|
"event": "job",
|
||||||
|
"data": {
|
||||||
|
"name": TEST_JOB,
|
||||||
|
"reference": "test",
|
||||||
|
"uuid": ANY,
|
||||||
|
"progress": 50,
|
||||||
|
"stage": "test",
|
||||||
|
"done": None,
|
||||||
|
"parent_id": None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with job.start():
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
coresys.homeassistant.websocket._client.async_send_command.assert_called_with(
|
||||||
|
{
|
||||||
|
"type": "supervisor/event",
|
||||||
|
"data": {
|
||||||
|
"event": "job",
|
||||||
|
"data": {
|
||||||
|
"name": TEST_JOB,
|
||||||
|
"reference": "test",
|
||||||
|
"uuid": ANY,
|
||||||
|
"progress": 50,
|
||||||
|
"stage": "test",
|
||||||
|
"done": False,
|
||||||
|
"parent_id": None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
coresys.homeassistant.websocket._client.async_send_command.assert_called_with(
|
||||||
|
{
|
||||||
|
"type": "supervisor/event",
|
||||||
|
"data": {
|
||||||
|
"event": "job",
|
||||||
|
"data": {
|
||||||
|
"name": TEST_JOB,
|
||||||
|
"reference": "test",
|
||||||
|
"uuid": ANY,
|
||||||
|
"progress": 50,
|
||||||
|
"stage": "test",
|
||||||
|
"done": True,
|
||||||
|
"parent_id": None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@ -190,11 +190,8 @@ def _supervisor_event_message(event: str, data: dict[str, Any]) -> dict[str, Any
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def test_events_on_issue_changes(coresys: CoreSys):
|
async def test_events_on_issue_changes(coresys: CoreSys, ha_ws_client: AsyncMock):
|
||||||
"""Test events fired when an issue changes."""
|
"""Test events fired when an issue changes."""
|
||||||
with patch.object(
|
|
||||||
type(coresys.homeassistant.websocket), "async_send_message"
|
|
||||||
) as send_message:
|
|
||||||
# Creating an issue with a suggestion should fire exactly one issue changed event
|
# Creating an issue with a suggestion should fire exactly one issue changed event
|
||||||
assert coresys.resolution.issues == []
|
assert coresys.resolution.issues == []
|
||||||
assert coresys.resolution.suggestions == []
|
assert coresys.resolution.suggestions == []
|
||||||
@ -222,20 +219,23 @@ async def test_events_on_issue_changes(coresys: CoreSys):
|
|||||||
"reference": "test_repo",
|
"reference": "test_repo",
|
||||||
"uuid": suggestion.uuid,
|
"uuid": suggestion.uuid,
|
||||||
}
|
}
|
||||||
send_message.assert_called_once_with(
|
assert _supervisor_event_message(
|
||||||
_supervisor_event_message(
|
|
||||||
"issue_changed", issue_expected | {"suggestions": [suggestion_expected]}
|
"issue_changed", issue_expected | {"suggestions": [suggestion_expected]}
|
||||||
)
|
) in [call.args[0] for call in ha_ws_client.async_send_command.call_args_list]
|
||||||
)
|
|
||||||
|
|
||||||
# Adding a suggestion that fixes the issue changes it
|
# Adding a suggestion that fixes the issue changes it
|
||||||
send_message.reset_mock()
|
ha_ws_client.async_send_command.reset_mock()
|
||||||
coresys.resolution.suggestions = execute_remove = Suggestion(
|
coresys.resolution.suggestions = execute_remove = Suggestion(
|
||||||
SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "test_repo"
|
SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "test_repo"
|
||||||
)
|
)
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
send_message.assert_called_once()
|
messages = [
|
||||||
sent_data = send_message.call_args.args[0]
|
call.args[0]
|
||||||
|
for call in ha_ws_client.async_send_command.call_args_list
|
||||||
|
if call.args[0].get("data", {}).get("event") == "issue_changed"
|
||||||
|
]
|
||||||
|
assert len(messages) == 1
|
||||||
|
sent_data = messages[0]
|
||||||
assert sent_data["type"] == "supervisor/event"
|
assert sent_data["type"] == "supervisor/event"
|
||||||
assert sent_data["data"]["event"] == "issue_changed"
|
assert sent_data["data"]["event"] == "issue_changed"
|
||||||
assert sent_data["data"]["data"].items() >= issue_expected.items()
|
assert sent_data["data"]["data"].items() >= issue_expected.items()
|
||||||
@ -249,24 +249,22 @@ async def test_events_on_issue_changes(coresys: CoreSys):
|
|||||||
} in sent_data["data"]["data"]["suggestions"]
|
} in sent_data["data"]["data"]["suggestions"]
|
||||||
|
|
||||||
# Removing a suggestion that fixes the issue changes it again
|
# Removing a suggestion that fixes the issue changes it again
|
||||||
send_message.reset_mock()
|
ha_ws_client.async_send_command.reset_mock()
|
||||||
coresys.resolution.dismiss_suggestion(execute_remove)
|
coresys.resolution.dismiss_suggestion(execute_remove)
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
send_message.assert_called_once_with(
|
assert _supervisor_event_message(
|
||||||
_supervisor_event_message(
|
|
||||||
"issue_changed", issue_expected | {"suggestions": [suggestion_expected]}
|
"issue_changed", issue_expected | {"suggestions": [suggestion_expected]}
|
||||||
)
|
) in [call.args[0] for call in ha_ws_client.async_send_command.call_args_list]
|
||||||
)
|
|
||||||
|
|
||||||
# Applying a suggestion should only fire an issue removed event
|
# Applying a suggestion should only fire an issue removed event
|
||||||
send_message.reset_mock()
|
ha_ws_client.async_send_command.reset_mock()
|
||||||
with patch("shutil.disk_usage", return_value=(42, 42, 2 * (1024.0**3))):
|
with patch("shutil.disk_usage", return_value=(42, 42, 2 * (1024.0**3))):
|
||||||
await coresys.resolution.apply_suggestion(suggestion)
|
await coresys.resolution.apply_suggestion(suggestion)
|
||||||
|
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
send_message.assert_called_once_with(
|
assert _supervisor_event_message("issue_removed", issue_expected) in [
|
||||||
_supervisor_event_message("issue_removed", issue_expected)
|
call.args[0] for call in ha_ws_client.async_send_command.call_args_list
|
||||||
)
|
]
|
||||||
|
|
||||||
|
|
||||||
async def test_resolution_apply_suggestion_multiple_copies(coresys: CoreSys):
|
async def test_resolution_apply_suggestion_multiple_copies(coresys: CoreSys):
|
||||||
|
Loading…
x
Reference in New Issue
Block a user