Compare commits

..

2 Commits

Author SHA1 Message Date
J. Nick Koston
af3256e41e Significantly speed up creating backups with isal via zlib-fast
isal is a drop in replacement for zlib with the
cavet that the compression level mappings are different.
zlib-fast is a tiny piece of middleware to convert
the standard zlib compression levels to isal compression
levels to allow for drop-in replacement

https://github.com/bdraco/zlib-fast/releases/tag/v0.1.0
https://github.com/pycompression/python-isal

Compression for backups is ~5x faster than the baseline

https://github.com/powturbo/TurboBench/issues/43
2024-01-27 13:06:41 -10:00
J. Nick Koston
a163121ad4 Fix dirhash failing to import pkg_resources
dirhash needs pkg_resources which is provided by setuptools

https://github.com/home-assistant/supervisor/actions/runs/7513346221/job/20454994962
2024-01-14 00:02:12 -10:00
43 changed files with 289 additions and 1308 deletions

View File

@@ -33,7 +33,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v4.0.0
uses: actions/cache@v3.3.3
with:
path: venv
key: |
@@ -47,7 +47,7 @@ jobs:
pip install -r requirements.txt -r requirements_tests.txt
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@v4.0.0
uses: actions/cache@v3.3.3
with:
path: ${{ env.PRE_COMMIT_CACHE }}
lookup-only: true
@@ -75,7 +75,7 @@ jobs:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v4.0.0
uses: actions/cache@v3.3.3
with:
path: venv
key: |
@@ -119,7 +119,7 @@ jobs:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v4.0.0
uses: actions/cache@v3.3.3
with:
path: venv
key: |
@@ -131,7 +131,7 @@ jobs:
exit 1
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@v4.0.0
uses: actions/cache@v3.3.3
with:
path: ${{ env.PRE_COMMIT_CACHE }}
key: |
@@ -163,7 +163,7 @@ jobs:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v4.0.0
uses: actions/cache@v3.3.3
with:
path: venv
key: |
@@ -195,7 +195,7 @@ jobs:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v4.0.0
uses: actions/cache@v3.3.3
with:
path: venv
key: |
@@ -207,7 +207,7 @@ jobs:
exit 1
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@v4.0.0
uses: actions/cache@v3.3.3
with:
path: ${{ env.PRE_COMMIT_CACHE }}
key: |
@@ -236,7 +236,7 @@ jobs:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v4.0.0
uses: actions/cache@v3.3.3
with:
path: venv
key: |
@@ -248,7 +248,7 @@ jobs:
exit 1
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@v4.0.0
uses: actions/cache@v3.3.3
with:
path: ${{ env.PRE_COMMIT_CACHE }}
key: |
@@ -280,7 +280,7 @@ jobs:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v4.0.0
uses: actions/cache@v3.3.3
with:
path: venv
key: |
@@ -312,7 +312,7 @@ jobs:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v4.0.0
uses: actions/cache@v3.3.3
with:
path: venv
key: |
@@ -324,7 +324,7 @@ jobs:
exit 1
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@v4.0.0
uses: actions/cache@v3.3.3
with:
path: ${{ env.PRE_COMMIT_CACHE }}
key: |
@@ -357,7 +357,7 @@ jobs:
cosign-release: "v2.0.2"
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v4.0.0
uses: actions/cache@v3.3.3
with:
path: venv
key: |
@@ -392,7 +392,7 @@ jobs:
-o console_output_style=count \
tests
- name: Upload coverage artifact
uses: actions/upload-artifact@v4.3.0
uses: actions/upload-artifact@v4.0.0
with:
name: coverage-${{ matrix.python-version }}
path: .coverage
@@ -411,7 +411,7 @@ jobs:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v4.0.0
uses: actions/cache@v3.3.3
with:
path: venv
key: |
@@ -430,4 +430,4 @@ jobs:
coverage report
coverage xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3.1.6
uses: codecov/codecov-action@v3.1.4

View File

@@ -12,7 +12,7 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.1.1
- name: Sentry Release
uses: getsentry/action-release@v1.7.0
uses: getsentry/action-release@v1.6.0
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}

View File

@@ -1,5 +1,5 @@
aiodns==3.1.1
aiohttp==3.9.3
aiohttp==3.9.1
aiohttp-fast-url-dispatcher==0.3.0
async_timeout==4.0.3
atomicwrites-homeassistant==1.4.1
@@ -7,9 +7,9 @@ attrs==23.2.0
awesomeversion==23.11.0
brotli==1.1.0
ciso8601==2.3.1
colorlog==6.8.2
colorlog==6.8.0
cpe==1.2.1
cryptography==42.0.1
cryptography==41.0.7
debugpy==1.8.0
deepmerge==1.1.1
dirhash==0.2.1
@@ -17,14 +17,14 @@ docker==7.0.0
faust-cchardet==2.1.19
gitpython==3.1.41
jinja2==3.1.3
orjson==3.9.12
orjson==3.9.10
pulsectl==23.5.2
pyudev==0.24.1
PyYAML==6.0.1
securetar==2023.12.0
sentry-sdk==1.40.0
sentry-sdk==1.39.2
setuptools==69.0.3
voluptuous==0.14.1
dbus-fast==2.21.1
dbus-fast==2.21.0
typing_extensions==4.9.0
zlib-fast==0.2.0
zlib-fast==0.1.0

View File

@@ -1,5 +1,5 @@
black==23.12.1
coverage==7.4.1
coverage==7.4.0
flake8-docstrings==1.7.0
flake8==7.0.0
pre-commit==3.6.0
@@ -13,4 +13,4 @@ pytest==7.4.4
pyupgrade==3.15.0
time-machine==2.13.0
typing_extensions==4.9.0
urllib3==2.2.0
urllib3==2.1.0

View File

@@ -10,10 +10,8 @@ import zlib_fast
# Enable fast zlib before importing supervisor
zlib_fast.enable()
from supervisor import bootstrap # pylint: disable=wrong-import-position # noqa: E402
from supervisor.utils.logging import ( # pylint: disable=wrong-import-position # noqa: E402
activate_log_queue_handler,
)
from supervisor import bootstrap # noqa: E402
from supervisor.utils.logging import activate_log_queue_handler # noqa: E402
_LOGGER: logging.Logger = logging.getLogger(__name__)

View File

@@ -3,7 +3,6 @@ import asyncio
from collections.abc import Awaitable
from contextlib import suppress
from copy import deepcopy
from datetime import datetime
import errno
from ipaddress import IPv4Address
import logging
@@ -16,7 +15,6 @@ from tempfile import TemporaryDirectory
from typing import Any, Final
import aiohttp
from awesomeversion import AwesomeVersionCompareException
from deepmerge import Merger
from securetar import atomic_contents_add, secure_path
import voluptuous as vol
@@ -48,7 +46,6 @@ from ..const import (
ATTR_USER,
ATTR_UUID,
ATTR_VERSION,
ATTR_VERSION_TIMESTAMP,
ATTR_WATCHDOG,
DNS_SUFFIX,
AddonBoot,
@@ -282,28 +279,6 @@ class Addon(AddonModel):
"""Set auto update."""
self.persist[ATTR_AUTO_UPDATE] = value
@property
def auto_update_available(self) -> bool:
"""Return if it is safe to auto update addon."""
if not self.need_update or not self.auto_update:
return False
for version in self.breaking_versions:
try:
# Must update to latest so if true update crosses a breaking version
if self.version < version:
return False
except AwesomeVersionCompareException:
# If version scheme changed, we may get compare exception
# If latest version >= breaking version then assume update will
# cross it as the version scheme changes
# If both versions have compare exception, ignore as its in the past
with suppress(AwesomeVersionCompareException):
if self.latest_version >= version:
return False
return True
@property
def watchdog(self) -> bool:
"""Return True if watchdog is enable."""
@@ -346,11 +321,6 @@ class Addon(AddonModel):
"""Return version of add-on."""
return self.data_store[ATTR_VERSION]
@property
def latest_version_timestamp(self) -> datetime:
"""Return when latest version was first seen."""
return datetime.fromtimestamp(self.data_store[ATTR_VERSION_TIMESTAMP])
@property
def protected(self) -> bool:
"""Return if add-on is in protected mode."""
@@ -800,7 +770,6 @@ class Addon(AddonModel):
raise AddonsError() from err
self.sys_addons.data.update(self.addon_store)
await self._check_ingress_port()
_LOGGER.info("Add-on '%s' successfully rebuilt", self.slug)
finally:
@@ -1252,7 +1221,7 @@ class Addon(AddonModel):
_LOGGER.info("Restore/Update of image for addon %s", self.slug)
with suppress(DockerError):
await self.instance.update(version, restore_image, self.arch)
await self._check_ingress_port()
self._check_ingress_port()
# Restore data and config
def _restore_data():

View File

@@ -28,7 +28,6 @@ class MappingType(StrEnum):
ATTR_BACKUP = "backup"
ATTR_BREAKING_VERSIONS = "breaking_versions"
ATTR_CODENOTARY = "codenotary"
ATTR_READ_ONLY = "read_only"
ATTR_PATH = "path"

View File

@@ -3,7 +3,6 @@ from abc import ABC, abstractmethod
from collections import defaultdict
from collections.abc import Callable
from contextlib import suppress
from datetime import datetime
import logging
from pathlib import Path
from typing import Any
@@ -72,7 +71,6 @@ from ..const import (
ATTR_URL,
ATTR_USB,
ATTR_VERSION,
ATTR_VERSION_TIMESTAMP,
ATTR_VIDEO,
ATTR_WATCHDOG,
ATTR_WEBUI,
@@ -92,7 +90,6 @@ from ..utils import version_is_new_enough
from .configuration import FolderMapping
from .const import (
ATTR_BACKUP,
ATTR_BREAKING_VERSIONS,
ATTR_CODENOTARY,
ATTR_PATH,
ATTR_READ_ONLY,
@@ -224,11 +221,6 @@ class AddonModel(JobGroup, ABC):
"""Return latest version of add-on."""
return self.data[ATTR_VERSION]
@property
def latest_version_timestamp(self) -> datetime:
"""Return when latest version was first seen."""
return datetime.fromtimestamp(self.data[ATTR_VERSION_TIMESTAMP])
@property
def version(self) -> AwesomeVersion:
"""Return version of add-on."""
@@ -628,11 +620,6 @@ class AddonModel(JobGroup, ABC):
"""Return Signer email address for CAS."""
return self.data.get(ATTR_CODENOTARY)
@property
def breaking_versions(self) -> list[AwesomeVersion]:
"""Return breaking versions of addon."""
return self.data[ATTR_BREAKING_VERSIONS]
def validate_availability(self) -> None:
"""Validate if addon is available for current system."""
return self._validate_availability(self.data, logger=_LOGGER.error)

View File

@@ -112,7 +112,6 @@ from ..validate import (
)
from .const import (
ATTR_BACKUP,
ATTR_BREAKING_VERSIONS,
ATTR_CODENOTARY,
ATTR_PATH,
ATTR_READ_ONLY,
@@ -423,7 +422,6 @@ _SCHEMA_ADDON_CONFIG = vol.Schema(
vol.Coerce(int), vol.Range(min=10, max=300)
),
vol.Optional(ATTR_JOURNALD, default=False): vol.Boolean(),
vol.Optional(ATTR_BREAKING_VERSIONS, default=list): [version_tag],
},
extra=vol.REMOVE_EXTRA,
)

View File

@@ -219,8 +219,6 @@ class RestAPI(CoreSysAttributes):
web.get("/jobs/info", api_jobs.info),
web.post("/jobs/options", api_jobs.options),
web.post("/jobs/reset", api_jobs.reset),
web.get("/jobs/{uuid}", api_jobs.job_info),
web.delete("/jobs/{uuid}", api_jobs.remove_job),
]
)

View File

@@ -1,6 +1,5 @@
"""Backups RESTful API."""
import asyncio
from collections.abc import Callable
import errno
import logging
from pathlib import Path
@@ -12,7 +11,6 @@ from aiohttp import web
from aiohttp.hdrs import CONTENT_DISPOSITION
import voluptuous as vol
from ..backups.backup import Backup
from ..backups.validate import ALL_FOLDERS, FOLDER_HOMEASSISTANT, days_until_stale
from ..const import (
ATTR_ADDONS,
@@ -35,15 +33,12 @@ from ..const import (
ATTR_TIMEOUT,
ATTR_TYPE,
ATTR_VERSION,
BusEvent,
CoreState,
)
from ..coresys import CoreSysAttributes
from ..exceptions import APIError
from ..jobs import JobSchedulerOptions
from ..mounts.const import MountUsage
from ..resolution.const import UnhealthyReason
from .const import ATTR_BACKGROUND, ATTR_JOB_ID, CONTENT_TYPE_TAR
from .const import CONTENT_TYPE_TAR
from .utils import api_process, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -55,21 +50,17 @@ RE_SLUGIFY_NAME = re.compile(r"[^A-Za-z0-9]+")
_ALL_FOLDERS = ALL_FOLDERS + [FOLDER_HOMEASSISTANT]
# pylint: disable=no-value-for-parameter
SCHEMA_RESTORE_FULL = vol.Schema(
SCHEMA_RESTORE_PARTIAL = vol.Schema(
{
vol.Optional(ATTR_PASSWORD): vol.Maybe(str),
vol.Optional(ATTR_BACKGROUND, default=False): vol.Boolean(),
}
)
SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
{
vol.Optional(ATTR_HOMEASSISTANT): vol.Boolean(),
vol.Optional(ATTR_ADDONS): vol.All([str], vol.Unique()),
vol.Optional(ATTR_FOLDERS): vol.All([vol.In(_ALL_FOLDERS)], vol.Unique()),
}
)
SCHEMA_RESTORE_FULL = vol.Schema({vol.Optional(ATTR_PASSWORD): vol.Maybe(str)})
SCHEMA_BACKUP_FULL = vol.Schema(
{
vol.Optional(ATTR_NAME): str,
@@ -77,7 +68,6 @@ SCHEMA_BACKUP_FULL = vol.Schema(
vol.Optional(ATTR_COMPRESSED): vol.Maybe(vol.Boolean()),
vol.Optional(ATTR_LOCATON): vol.Maybe(str),
vol.Optional(ATTR_HOMEASSISTANT_EXCLUDE_DATABASE): vol.Boolean(),
vol.Optional(ATTR_BACKGROUND, default=False): vol.Boolean(),
}
)
@@ -214,109 +204,46 @@ class APIBackups(CoreSysAttributes):
return body
async def _background_backup_task(
self, backup_method: Callable, *args, **kwargs
) -> tuple[asyncio.Task, str]:
"""Start backup task in background and return task and job ID."""
event = asyncio.Event()
job, backup_task = self.sys_jobs.schedule_job(
backup_method, JobSchedulerOptions(), *args, **kwargs
)
async def release_on_freeze(new_state: CoreState):
if new_state == CoreState.FREEZE:
event.set()
# Wait for system to get into freeze state before returning
# If the backup fails validation it will raise before getting there
listener = self.sys_bus.register_event(
BusEvent.SUPERVISOR_STATE_CHANGE, release_on_freeze
)
try:
await asyncio.wait(
(
backup_task,
self.sys_create_task(event.wait()),
),
return_when=asyncio.FIRST_COMPLETED,
)
return (backup_task, job.uuid)
finally:
self.sys_bus.remove_listener(listener)
@api_process
async def backup_full(self, request):
"""Create full backup."""
body = await api_validate(SCHEMA_BACKUP_FULL, request)
background = body.pop(ATTR_BACKGROUND)
backup_task, job_id = await self._background_backup_task(
self.sys_backups.do_backup_full, **self._location_to_mount(body)
backup = await asyncio.shield(
self.sys_backups.do_backup_full(**self._location_to_mount(body))
)
if background and not backup_task.done():
return {ATTR_JOB_ID: job_id}
backup: Backup = await backup_task
if backup:
return {ATTR_JOB_ID: job_id, ATTR_SLUG: backup.slug}
raise APIError(
f"An error occurred while making backup, check job '{job_id}' or supervisor logs for details",
job_id=job_id,
)
return {ATTR_SLUG: backup.slug}
return False
@api_process
async def backup_partial(self, request):
"""Create a partial backup."""
body = await api_validate(SCHEMA_BACKUP_PARTIAL, request)
background = body.pop(ATTR_BACKGROUND)
backup_task, job_id = await self._background_backup_task(
self.sys_backups.do_backup_partial, **self._location_to_mount(body)
backup = await asyncio.shield(
self.sys_backups.do_backup_partial(**self._location_to_mount(body))
)
if background and not backup_task.done():
return {ATTR_JOB_ID: job_id}
backup: Backup = await backup_task
if backup:
return {ATTR_JOB_ID: job_id, ATTR_SLUG: backup.slug}
raise APIError(
f"An error occurred while making backup, check job '{job_id}' or supervisor logs for details",
job_id=job_id,
)
return {ATTR_SLUG: backup.slug}
return False
@api_process
async def restore_full(self, request):
"""Full restore of a backup."""
backup = self._extract_slug(request)
body = await api_validate(SCHEMA_RESTORE_FULL, request)
background = body.pop(ATTR_BACKGROUND)
restore_task, job_id = await self._background_backup_task(
self.sys_backups.do_restore_full, backup, **body
)
if background and not restore_task.done() or await restore_task:
return {ATTR_JOB_ID: job_id}
raise APIError(
f"An error occurred during restore of {backup.slug}, check job '{job_id}' or supervisor logs for details",
job_id=job_id,
)
return await asyncio.shield(self.sys_backups.do_restore_full(backup, **body))
@api_process
async def restore_partial(self, request):
"""Partial restore a backup."""
backup = self._extract_slug(request)
body = await api_validate(SCHEMA_RESTORE_PARTIAL, request)
background = body.pop(ATTR_BACKGROUND)
restore_task, job_id = await self._background_backup_task(
self.sys_backups.do_restore_partial, backup, **body
)
if background and not restore_task.done() or await restore_task:
return {ATTR_JOB_ID: job_id}
raise APIError(
f"An error occurred during restore of {backup.slug}, check job '{job_id}' or supervisor logs for details",
job_id=job_id,
)
return await asyncio.shield(self.sys_backups.do_restore_partial(backup, **body))
@api_process
async def freeze(self, request):

View File

@@ -13,7 +13,6 @@ ATTR_AGENT_VERSION = "agent_version"
ATTR_APPARMOR_VERSION = "apparmor_version"
ATTR_ATTRIBUTES = "attributes"
ATTR_AVAILABLE_UPDATES = "available_updates"
ATTR_BACKGROUND = "background"
ATTR_BOOT_TIMESTAMP = "boot_timestamp"
ATTR_BOOTS = "boots"
ATTR_BROADCAST_LLMNR = "broadcast_llmnr"
@@ -32,7 +31,6 @@ ATTR_EJECTABLE = "ejectable"
ATTR_FALLBACK = "fallback"
ATTR_FILESYSTEMS = "filesystems"
ATTR_IDENTIFIERS = "identifiers"
ATTR_JOB_ID = "job_id"
ATTR_JOBS = "jobs"
ATTR_LLMNR = "llmnr"
ATTR_LLMNR_HOSTNAME = "llmnr_hostname"

View File

@@ -6,7 +6,6 @@ from aiohttp import web
import voluptuous as vol
from ..coresys import CoreSysAttributes
from ..exceptions import APIError
from ..jobs import SupervisorJob
from ..jobs.const import ATTR_IGNORE_CONDITIONS, JobCondition
from .const import ATTR_JOBS
@@ -22,7 +21,7 @@ SCHEMA_OPTIONS = vol.Schema(
class APIJobs(CoreSysAttributes):
"""Handle RESTful API for OS functions."""
def _list_jobs(self, start: SupervisorJob | None = None) -> list[dict[str, Any]]:
def _list_jobs(self) -> list[dict[str, Any]]:
"""Return current job tree."""
jobs_by_parent: dict[str | None, list[SupervisorJob]] = {}
for job in self.sys_jobs.jobs:
@@ -35,11 +34,9 @@ class APIJobs(CoreSysAttributes):
jobs_by_parent[job.parent_id].append(job)
job_list: list[dict[str, Any]] = []
queue: list[tuple[list[dict[str, Any]], SupervisorJob]] = (
[(job_list, start)]
if start
else [(job_list, job) for job in jobs_by_parent.get(None, [])]
)
queue: list[tuple[list[dict[str, Any]], SupervisorJob]] = [
(job_list, job) for job in jobs_by_parent.get(None, [])
]
while queue:
(current_list, current_job) = queue.pop(0)
@@ -81,19 +78,3 @@ class APIJobs(CoreSysAttributes):
async def reset(self, request: web.Request) -> None:
"""Reset options for JobManager."""
self.sys_jobs.reset_data()
@api_process
async def job_info(self, request: web.Request) -> dict[str, Any]:
"""Get details of a job by ID."""
job = self.sys_jobs.get_job(request.match_info.get("uuid"))
return self._list_jobs(job)[0]
@api_process
async def remove_job(self, request: web.Request) -> None:
"""Remove a completed job."""
job = self.sys_jobs.get_job(request.match_info.get("uuid"))
if not job.done:
raise APIError(f"Job {job.uuid} is not done!")
self.sys_jobs.remove_job(job)

View File

@@ -14,7 +14,6 @@ from aiohttp.web_exceptions import HTTPBadGateway, HTTPUnauthorized
from ..coresys import CoreSysAttributes
from ..exceptions import APIError, HomeAssistantAPIError, HomeAssistantAuthError
from ..utils.json import json_dumps
_LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -146,8 +145,7 @@ class APIProxy(CoreSysAttributes):
{
"type": "auth",
"access_token": self.sys_homeassistant.api.access_token,
},
dumps=json_dumps,
}
)
data = await client.receive_json()
@@ -204,8 +202,7 @@ class APIProxy(CoreSysAttributes):
# handle authentication
try:
await server.send_json(
{"type": "auth_required", "ha_version": self.sys_homeassistant.version},
dumps=json_dumps,
{"type": "auth_required", "ha_version": self.sys_homeassistant.version}
)
# Check API access
@@ -218,16 +215,14 @@ class APIProxy(CoreSysAttributes):
if not addon or not addon.access_homeassistant_api:
_LOGGER.warning("Unauthorized WebSocket access!")
await server.send_json(
{"type": "auth_invalid", "message": "Invalid access"},
dumps=json_dumps,
{"type": "auth_invalid", "message": "Invalid access"}
)
return server
_LOGGER.info("WebSocket access from %s", addon.slug)
await server.send_json(
{"type": "auth_ok", "ha_version": self.sys_homeassistant.version},
dumps=json_dumps,
{"type": "auth_ok", "ha_version": self.sys_homeassistant.version}
)
except (RuntimeError, ValueError) as err:
_LOGGER.error("Can't initialize handshake: %s", err)

View File

@@ -13,7 +13,6 @@ from ..const import (
HEADER_TOKEN,
HEADER_TOKEN_OLD,
JSON_DATA,
JSON_JOB_ID,
JSON_MESSAGE,
JSON_RESULT,
REQUEST_FROM,
@@ -125,15 +124,11 @@ def api_return_error(
if check_exception_chain(error, DockerAPIError):
message = format_message(message)
result = {
JSON_RESULT: RESULT_ERROR,
JSON_MESSAGE: message or "Unknown error, see supervisor",
}
if isinstance(error, APIError) and error.job_id:
result[JSON_JOB_ID] = error.job_id
return web.json_response(
result,
{
JSON_RESULT: RESULT_ERROR,
JSON_MESSAGE: message or "Unknown error, see supervisor",
},
status=400,
dumps=json_dumps,
)

View File

@@ -1,9 +1,7 @@
"""Representation of a backup file."""
import asyncio
from base64 import b64decode, b64encode
from collections import defaultdict
from collections.abc import Awaitable
from copy import deepcopy
from datetime import timedelta
from functools import cached_property
import json
@@ -44,11 +42,8 @@ from ..const import (
ATTR_VERSION,
CRYPTO_AES128,
)
from ..coresys import CoreSys
from ..exceptions import AddonsError, BackupError, BackupInvalidError
from ..jobs.const import JOB_GROUP_BACKUP
from ..jobs.decorator import Job
from ..jobs.job_group import JobGroup
from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import AddonsError, BackupError
from ..utils import remove_folder
from ..utils.dt import parse_datetime, utcnow
from ..utils.json import write_json_file
@@ -59,22 +54,14 @@ from .validate import SCHEMA_BACKUP
_LOGGER: logging.Logger = logging.getLogger(__name__)
class Backup(JobGroup):
class Backup(CoreSysAttributes):
"""A single Supervisor backup."""
def __init__(
self,
coresys: CoreSys,
tar_file: Path,
slug: str,
data: dict[str, Any] | None = None,
):
def __init__(self, coresys: CoreSys, tar_file: Path):
"""Initialize a backup."""
super().__init__(
coresys, JOB_GROUP_BACKUP.format_map(defaultdict(str, slug=slug)), slug
)
self.coresys: CoreSys = coresys
self._tarfile: Path = tar_file
self._data: dict[str, Any] = data or {ATTR_SLUG: slug}
self._data: dict[str, Any] = {}
self._tmp = None
self._key: bytes | None = None
self._aes: Cipher | None = None
@@ -100,7 +87,7 @@ class Backup(JobGroup):
return self._data[ATTR_NAME]
@property
def date(self) -> str:
def date(self):
"""Return backup date."""
return self._data[ATTR_DATE]
@@ -115,32 +102,32 @@ class Backup(JobGroup):
return self._data[ATTR_COMPRESSED]
@property
def addons(self) -> list[dict[str, Any]]:
def addons(self):
"""Return backup date."""
return self._data[ATTR_ADDONS]
@property
def addon_list(self) -> list[str]:
def addon_list(self):
"""Return a list of add-ons slugs."""
return [addon_data[ATTR_SLUG] for addon_data in self.addons]
@property
def folders(self) -> list[str]:
def folders(self):
"""Return list of saved folders."""
return self._data[ATTR_FOLDERS]
@property
def repositories(self) -> list[str]:
def repositories(self):
"""Return backup date."""
return self._data[ATTR_REPOSITORIES]
@repositories.setter
def repositories(self, value: list[str]) -> None:
def repositories(self, value):
"""Set backup date."""
self._data[ATTR_REPOSITORIES] = value
@property
def homeassistant_version(self) -> AwesomeVersion:
def homeassistant_version(self):
"""Return backup Home Assistant version."""
if self.homeassistant is None:
return None
@@ -154,7 +141,7 @@ class Backup(JobGroup):
return self.homeassistant[ATTR_EXCLUDE_DATABASE]
@property
def homeassistant(self) -> dict[str, Any]:
def homeassistant(self):
"""Return backup Home Assistant data."""
return self._data[ATTR_HOMEASSISTANT]
@@ -164,12 +151,12 @@ class Backup(JobGroup):
return self._data[ATTR_SUPERVISOR_VERSION]
@property
def docker(self) -> dict[str, Any]:
def docker(self):
"""Return backup Docker config data."""
return self._data.get(ATTR_DOCKER, {})
@docker.setter
def docker(self, value: dict[str, Any]) -> None:
def docker(self, value):
"""Set the Docker config data."""
self._data[ATTR_DOCKER] = value
@@ -182,36 +169,32 @@ class Backup(JobGroup):
return None
@property
def size(self) -> float:
def size(self):
"""Return backup size."""
if not self.tarfile.is_file():
return 0
return round(self.tarfile.stat().st_size / 1048576, 2) # calc mbyte
@property
def is_new(self) -> bool:
def is_new(self):
"""Return True if there is new."""
return not self.tarfile.exists()
@property
def tarfile(self) -> Path:
def tarfile(self):
"""Return path to backup tarfile."""
return self._tarfile
@property
def is_current(self) -> bool:
def is_current(self):
"""Return true if backup is current, false if stale."""
return parse_datetime(self.date) >= utcnow() - timedelta(
days=self.sys_backups.days_until_stale
)
@property
def data(self) -> dict[str, Any]:
"""Returns a copy of the data."""
return deepcopy(self._data)
def new(
self,
slug: str,
name: str,
date: str,
sys_type: BackupType,
@@ -221,6 +204,7 @@ class Backup(JobGroup):
"""Initialize a new backup."""
# Init metadata
self._data[ATTR_VERSION] = 2
self._data[ATTR_SLUG] = slug
self._data[ATTR_NAME] = name
self._data[ATTR_DATE] = date
self._data[ATTR_TYPE] = sys_type
@@ -365,240 +349,152 @@ class Backup(JobGroup):
write_json_file(Path(self._tmp.name, "backup.json"), self._data)
await self.sys_run_in_executor(_create_backup)
except (OSError, json.JSONDecodeError) as err:
self.sys_jobs.current.capture_error(BackupError("Can't write backup"))
_LOGGER.error("Can't write backup: %s", err)
finally:
self._tmp.cleanup()
@Job(name="backup_addon_save", cleanup=False)
async def _addon_save(self, addon: Addon) -> asyncio.Task | None:
"""Store an add-on into backup."""
self.sys_jobs.current.reference = addon.slug
tar_name = f"{addon.slug}.tar{'.gz' if self.compressed else ''}"
addon_file = SecureTarFile(
Path(self._tmp.name, tar_name),
"w",
key=self._key,
gzip=self.compressed,
bufsize=BUF_SIZE,
)
# Take backup
try:
start_task = await addon.backup(addon_file)
except AddonsError as err:
raise BackupError(
f"Can't create backup for {addon.slug}", _LOGGER.error
) from err
# Store to config
self._data[ATTR_ADDONS].append(
{
ATTR_SLUG: addon.slug,
ATTR_NAME: addon.name,
ATTR_VERSION: addon.version,
ATTR_SIZE: addon_file.size,
}
)
return start_task
@Job(name="backup_store_addons", cleanup=False)
async def store_addons(self, addon_list: list[str]) -> list[asyncio.Task]:
"""Add a list of add-ons into backup.
For each addon that needs to be started after backup, returns a Task which
completes when that addon has state 'started' (see addon.start).
"""
# Save Add-ons sequential avoid issue on slow IO
async def _addon_save(addon: Addon) -> asyncio.Task | None:
"""Task to store an add-on into backup."""
tar_name = f"{addon.slug}.tar{'.gz' if self.compressed else ''}"
addon_file = SecureTarFile(
Path(self._tmp.name, tar_name),
"w",
key=self._key,
gzip=self.compressed,
bufsize=BUF_SIZE,
)
# Take backup
try:
start_task = await addon.backup(addon_file)
except AddonsError:
_LOGGER.error("Can't create backup for %s", addon.slug)
return
# Store to config
self._data[ATTR_ADDONS].append(
{
ATTR_SLUG: addon.slug,
ATTR_NAME: addon.name,
ATTR_VERSION: addon.version,
ATTR_SIZE: addon_file.size,
}
)
return start_task
# Save Add-ons sequential
# avoid issue on slow IO
start_tasks: list[asyncio.Task] = []
for addon in addon_list:
try:
if start_task := await self._addon_save(addon):
if start_task := await _addon_save(addon):
start_tasks.append(start_task)
except Exception as err: # pylint: disable=broad-except
_LOGGER.warning("Can't save Add-on %s: %s", addon.slug, err)
return start_tasks
@Job(name="backup_addon_restore", cleanup=False)
async def _addon_restore(self, addon_slug: str) -> asyncio.Task | None:
"""Restore an add-on from backup."""
self.sys_jobs.current.reference = addon_slug
tar_name = f"{addon_slug}.tar{'.gz' if self.compressed else ''}"
addon_file = SecureTarFile(
Path(self._tmp.name, tar_name),
"r",
key=self._key,
gzip=self.compressed,
bufsize=BUF_SIZE,
)
# If exists inside backup
if not addon_file.path.exists():
raise BackupError(f"Can't find backup {addon_slug}", _LOGGER.error)
# Perform a restore
try:
return await self.sys_addons.restore(addon_slug, addon_file)
except AddonsError as err:
raise BackupError(
f"Can't restore backup {addon_slug}", _LOGGER.error
) from err
@Job(name="backup_restore_addons", cleanup=False)
async def restore_addons(
self, addon_list: list[str]
) -> tuple[bool, list[asyncio.Task]]:
"""Restore a list add-on from backup."""
# Save Add-ons sequential avoid issue on slow IO
async def _addon_restore(addon_slug: str) -> tuple[bool, asyncio.Task | None]:
"""Task to restore an add-on into backup."""
tar_name = f"{addon_slug}.tar{'.gz' if self.compressed else ''}"
addon_file = SecureTarFile(
Path(self._tmp.name, tar_name),
"r",
key=self._key,
gzip=self.compressed,
bufsize=BUF_SIZE,
)
# If exists inside backup
if not addon_file.path.exists():
_LOGGER.error("Can't find backup %s", addon_slug)
return (False, None)
# Perform a restore
try:
return (True, await self.sys_addons.restore(addon_slug, addon_file))
except AddonsError:
_LOGGER.error("Can't restore backup %s", addon_slug)
return (False, None)
# Save Add-ons sequential
# avoid issue on slow IO
start_tasks: list[asyncio.Task] = []
success = True
for slug in addon_list:
try:
start_task = await self._addon_restore(slug)
addon_success, start_task = await _addon_restore(slug)
except Exception as err: # pylint: disable=broad-except
_LOGGER.warning("Can't restore Add-on %s: %s", slug, err)
success = False
else:
success = success and addon_success
if start_task:
start_tasks.append(start_task)
return (success, start_tasks)
@Job(name="backup_remove_delta_addons", cleanup=False)
async def remove_delta_addons(self) -> bool:
"""Remove addons which are not in this backup."""
success = True
for addon in self.sys_addons.installed:
if addon.slug in self.addon_list:
continue
# Remove Add-on because it's not a part of the new env
# Do it sequential avoid issue on slow IO
try:
await self.sys_addons.uninstall(addon.slug)
except AddonsError as err:
self.sys_jobs.current.capture_error(err)
_LOGGER.warning("Can't uninstall Add-on %s: %s", addon.slug, err)
success = False
return success
@Job(name="backup_folder_save", cleanup=False)
async def _folder_save(self, name: str):
"""Take backup of a folder."""
self.sys_jobs.current.reference = name
slug_name = name.replace("/", "_")
tar_name = Path(
self._tmp.name, f"{slug_name}.tar{'.gz' if self.compressed else ''}"
)
origin_dir = Path(self.sys_config.path_supervisor, name)
# Check if exists
if not origin_dir.is_dir():
_LOGGER.warning("Can't find backup folder %s", name)
return
def _save() -> None:
# Take backup
_LOGGER.info("Backing up folder %s", name)
with SecureTarFile(
tar_name, "w", key=self._key, gzip=self.compressed, bufsize=BUF_SIZE
) as tar_file:
atomic_contents_add(
tar_file,
origin_dir,
excludes=[
bound.bind_mount.local_where.as_posix()
for bound in self.sys_mounts.bound_mounts
if bound.bind_mount.local_where
],
arcname=".",
)
_LOGGER.info("Backup folder %s done", name)
try:
await self.sys_run_in_executor(_save)
except (tarfile.TarError, OSError) as err:
raise BackupError(
f"Can't backup folder {name}: {str(err)}", _LOGGER.error
) from err
self._data[ATTR_FOLDERS].append(name)
@Job(name="backup_store_folders", cleanup=False)
async def store_folders(self, folder_list: list[str]):
"""Backup Supervisor data into backup."""
# Save folder sequential avoid issue on slow IO
for folder in folder_list:
await self._folder_save(folder)
@Job(name="backup_folder_restore", cleanup=False)
async def _folder_restore(self, name: str) -> None:
"""Restore a folder."""
self.sys_jobs.current.reference = name
slug_name = name.replace("/", "_")
tar_name = Path(
self._tmp.name, f"{slug_name}.tar{'.gz' if self.compressed else ''}"
)
origin_dir = Path(self.sys_config.path_supervisor, name)
# Check if exists inside backup
if not tar_name.exists():
raise BackupInvalidError(
f"Can't find restore folder {name}", _LOGGER.warning
async def _folder_save(name: str):
"""Take backup of a folder."""
slug_name = name.replace("/", "_")
tar_name = Path(
self._tmp.name, f"{slug_name}.tar{'.gz' if self.compressed else ''}"
)
origin_dir = Path(self.sys_config.path_supervisor, name)
# Unmount any mounts within folder
bind_mounts = [
bound.bind_mount
for bound in self.sys_mounts.bound_mounts
if bound.bind_mount.local_where
and bound.bind_mount.local_where.is_relative_to(origin_dir)
]
if bind_mounts:
await asyncio.gather(*[bind_mount.unmount() for bind_mount in bind_mounts])
# Check if exists
if not origin_dir.is_dir():
_LOGGER.warning("Can't find backup folder %s", name)
return
# Clean old stuff
if origin_dir.is_dir():
await remove_folder(origin_dir, content_only=True)
# Perform a restore
def _restore() -> bool:
try:
_LOGGER.info("Restore folder %s", name)
def _save() -> None:
# Take backup
_LOGGER.info("Backing up folder %s", name)
with SecureTarFile(
tar_name,
"r",
key=self._key,
gzip=self.compressed,
bufsize=BUF_SIZE,
tar_name, "w", key=self._key, gzip=self.compressed, bufsize=BUF_SIZE
) as tar_file:
tar_file.extractall(
path=origin_dir, members=tar_file, filter="fully_trusted"
atomic_contents_add(
tar_file,
origin_dir,
excludes=[
bound.bind_mount.local_where.as_posix()
for bound in self.sys_mounts.bound_mounts
if bound.bind_mount.local_where
],
arcname=".",
)
_LOGGER.info("Restore folder %s done", name)
_LOGGER.info("Backup folder %s done", name)
await self.sys_run_in_executor(_save)
self._data[ATTR_FOLDERS].append(name)
# Save folder sequential
# avoid issue on slow IO
for folder in folder_list:
try:
await _folder_save(folder)
except (tarfile.TarError, OSError) as err:
raise BackupError(
f"Can't restore folder {name}: {err}", _LOGGER.warning
f"Can't backup folder {folder}: {str(err)}", _LOGGER.error
) from err
return True
try:
return await self.sys_run_in_executor(_restore)
finally:
if bind_mounts:
await asyncio.gather(
*[bind_mount.mount() for bind_mount in bind_mounts]
)
@Job(name="backup_restore_folders", cleanup=False)
async def restore_folders(self, folder_list: list[str]) -> bool:
"""Backup Supervisor data into backup."""
success = True
@@ -660,16 +556,16 @@ class Backup(JobGroup):
*[bind_mount.mount() for bind_mount in bind_mounts]
)
# Restore folder sequential avoid issue on slow IO
# Restore folder sequential
# avoid issue on slow IO
for folder in folder_list:
try:
await self._folder_restore(folder)
success = success and await _folder_restore(folder)
except Exception as err: # pylint: disable=broad-except
_LOGGER.warning("Can't restore folder %s: %s", folder, err)
success = False
return success
@Job(name="backup_store_homeassistant", cleanup=False)
async def store_homeassistant(self, exclude_database: bool = False):
"""Backup Home Assistant Core configuration folder."""
self._data[ATTR_HOMEASSISTANT] = {
@@ -690,7 +586,6 @@ class Backup(JobGroup):
# Store size
self.homeassistant[ATTR_SIZE] = homeassistant_file.size
@Job(name="backup_restore_homeassistant", cleanup=False)
async def restore_homeassistant(self) -> Awaitable[None]:
"""Restore Home Assistant Core configuration folder."""
await self.sys_homeassistant.core.stop()
@@ -724,7 +619,7 @@ class Backup(JobGroup):
return self.sys_create_task(_core_update())
def store_repositories(self) -> None:
def store_repositories(self):
"""Store repository list into backup."""
self.repositories = self.sys_store.repository_urls

View File

@@ -15,7 +15,7 @@ from ..const import (
CoreState,
)
from ..dbus.const import UnitActiveState
from ..exceptions import BackupError, BackupInvalidError, BackupJobError
from ..exceptions import AddonsError, BackupError, BackupInvalidError, BackupJobError
from ..jobs.const import JOB_GROUP_BACKUP_MANAGER, JobCondition, JobExecutionLimit
from ..jobs.decorator import Job
from ..jobs.job_group import JobGroup
@@ -139,8 +139,8 @@ class BackupManager(FileConfiguration, JobGroup):
tar_file = Path(self._get_base_path(location), f"{slug}.tar")
# init object
backup = Backup(self.coresys, tar_file, slug)
backup.new(name, date_str, sys_type, password, compressed)
backup = Backup(self.coresys, tar_file)
backup.new(slug, name, date_str, sys_type, password, compressed)
# Add backup ID to job
self.sys_jobs.current.reference = backup.slug
@@ -165,11 +165,9 @@ class BackupManager(FileConfiguration, JobGroup):
async def _load_backup(tar_file):
"""Load the backup."""
backup = Backup(self.coresys, tar_file, "temp")
backup = Backup(self.coresys, tar_file)
if await backup.load():
self._backups[backup.slug] = Backup(
self.coresys, tar_file, backup.slug, backup.data
)
self._backups[backup.slug] = backup
tasks = [
self.sys_create_task(_load_backup(tar_file))
@@ -201,7 +199,7 @@ class BackupManager(FileConfiguration, JobGroup):
async def import_backup(self, tar_file: Path) -> Backup | None:
"""Check backup tarfile and import it."""
backup = Backup(self.coresys, tar_file, "temp")
backup = Backup(self.coresys, tar_file)
# Read meta data
if not await backup.load():
@@ -224,7 +222,7 @@ class BackupManager(FileConfiguration, JobGroup):
return None
# Load new backup
backup = Backup(self.coresys, tar_origin, backup.slug, backup.data)
backup = Backup(self.coresys, tar_origin)
if not await backup.load():
return None
_LOGGER.info("Successfully imported %s", backup.slug)
@@ -271,15 +269,9 @@ class BackupManager(FileConfiguration, JobGroup):
self._change_stage(BackupJobStage.FINISHING_FILE, backup)
except BackupError as err:
self.sys_jobs.current.capture_error(err)
return None
except Exception as err: # pylint: disable=broad-except
_LOGGER.exception("Backup %s error", backup.slug)
capture_exception(err)
self.sys_jobs.current.capture_error(
BackupError(f"Backup {backup.slug} error, see supervisor logs")
)
return None
else:
self._backups[backup.slug] = backup
@@ -298,7 +290,6 @@ class BackupManager(FileConfiguration, JobGroup):
conditions=[JobCondition.RUNNING],
limit=JobExecutionLimit.GROUP_ONCE,
on_condition=BackupJobError,
cleanup=False,
)
async def do_backup_full(
self,
@@ -335,7 +326,6 @@ class BackupManager(FileConfiguration, JobGroup):
conditions=[JobCondition.RUNNING],
limit=JobExecutionLimit.GROUP_ONCE,
on_condition=BackupJobError,
cleanup=False,
)
async def do_backup_partial(
self,
@@ -420,7 +410,17 @@ class BackupManager(FileConfiguration, JobGroup):
# Delete delta add-ons
if replace:
self._change_stage(RestoreJobStage.REMOVE_DELTA_ADDONS, backup)
success = success and await backup.remove_delta_addons()
for addon in self.sys_addons.installed:
if addon.slug in backup.addon_list:
continue
# Remove Add-on because it's not a part of the new env
# Do it sequential avoid issue on slow IO
try:
await self.sys_addons.uninstall(addon.slug)
except AddonsError:
_LOGGER.warning("Can't uninstall Add-on %s", addon.slug)
success = False
if addon_list:
self._change_stage(RestoreJobStage.ADDON_REPOSITORIES, backup)
@@ -444,7 +444,7 @@ class BackupManager(FileConfiguration, JobGroup):
_LOGGER.exception("Restore %s error", backup.slug)
capture_exception(err)
raise BackupError(
f"Restore {backup.slug} error, see supervisor logs"
f"Restore {backup.slug} error, check logs for details"
) from err
else:
if addon_start_tasks:
@@ -463,16 +463,12 @@ class BackupManager(FileConfiguration, JobGroup):
# Do we need start Home Assistant Core?
if not await self.sys_homeassistant.core.is_running():
await self.sys_homeassistant.core.start(
_job_override__cleanup=False
)
await self.sys_homeassistant.core.start()
# Check If we can access to API / otherwise restart
if not await self.sys_homeassistant.api.check_api_state():
_LOGGER.warning("Need restart HomeAssistant for API")
await self.sys_homeassistant.core.restart(
_job_override__cleanup=False
)
await self.sys_homeassistant.core.restart()
@Job(
name="backup_manager_full_restore",
@@ -485,7 +481,6 @@ class BackupManager(FileConfiguration, JobGroup):
],
limit=JobExecutionLimit.GROUP_ONCE,
on_condition=BackupJobError,
cleanup=False,
)
async def do_restore_full(
self, backup: Backup, password: str | None = None
@@ -539,7 +534,6 @@ class BackupManager(FileConfiguration, JobGroup):
],
limit=JobExecutionLimit.GROUP_ONCE,
on_condition=BackupJobError,
cleanup=False,
)
async def do_restore_partial(
self,

View File

@@ -115,7 +115,7 @@ async def initialize_coresys() -> CoreSys:
_LOGGER.warning(
"Missing SUPERVISOR_MACHINE environment variable. Fallback to deprecated extraction!"
)
_LOGGER.info("Setting up coresys for machine: %s", coresys.machine)
_LOGGER.info("Seting up coresys for machine: %s", coresys.machine)
return coresys

View File

@@ -68,7 +68,6 @@ META_SUPERVISOR = "supervisor"
JSON_DATA = "data"
JSON_MESSAGE = "message"
JSON_RESULT = "result"
JSON_JOB_ID = "job_id"
RESULT_ERROR = "error"
RESULT_OK = "ok"
@@ -332,7 +331,6 @@ ATTR_UUID = "uuid"
ATTR_VALID = "valid"
ATTR_VALUE = "value"
ATTR_VERSION = "version"
ATTR_VERSION_TIMESTAMP = "version_timestamp"
ATTR_VERSION_LATEST = "version_latest"
ATTR_VIDEO = "video"
ATTR_VLAN = "vlan"
@@ -460,11 +458,9 @@ class HostFeature(StrEnum):
class BusEvent(StrEnum):
"""Bus event type."""
DOCKER_CONTAINER_STATE_CHANGE = "docker_container_state_change"
HARDWARE_NEW_DEVICE = "hardware_new_device"
HARDWARE_REMOVE_DEVICE = "hardware_remove_device"
SUPERVISOR_JOB_END = "supervisor_job_end"
SUPERVISOR_JOB_START = "supervisor_job_start"
DOCKER_CONTAINER_STATE_CHANGE = "docker_container_state_change"
SUPERVISOR_STATE_CHANGE = "supervisor_state_change"

View File

@@ -544,44 +544,13 @@ class CoreSys:
return self.loop.run_in_executor(None, funct, *args)
def _create_context(self) -> Context:
"""Create a new context for a task."""
def create_task(self, coroutine: Coroutine) -> asyncio.Task:
"""Create an async task."""
context = copy_context()
for callback in self._set_task_context:
context = callback(context)
return context
def create_task(self, coroutine: Coroutine) -> asyncio.Task:
"""Create an async task."""
return self.loop.create_task(coroutine, context=self._create_context())
def call_later(
self,
delay: float,
funct: Callable[..., Coroutine[Any, Any, T]],
*args: tuple[Any],
**kwargs: dict[str, Any],
) -> asyncio.TimerHandle:
"""Start a task after a delay."""
if kwargs:
funct = partial(funct, **kwargs)
return self.loop.call_later(delay, funct, *args, context=self._create_context())
def call_at(
self,
when: datetime,
funct: Callable[..., Coroutine[Any, Any, T]],
*args: tuple[Any],
**kwargs: dict[str, Any],
) -> asyncio.TimerHandle:
"""Start a task at the specified datetime."""
if kwargs:
funct = partial(funct, **kwargs)
return self.loop.call_at(
when.timestamp(), funct, *args, context=self._create_context()
)
return self.loop.create_task(coroutine, context=context)
class CoreSysAttributes:
@@ -762,23 +731,3 @@ class CoreSysAttributes:
def sys_create_task(self, coroutine: Coroutine) -> asyncio.Task:
"""Create an async task."""
return self.coresys.create_task(coroutine)
def sys_call_later(
self,
delay: float,
funct: Callable[..., Coroutine[Any, Any, T]],
*args: tuple[Any],
**kwargs: dict[str, Any],
) -> asyncio.TimerHandle:
"""Start a task after a delay."""
return self.coresys.call_later(delay, funct, *args, **kwargs)
def sys_call_at(
self,
when: datetime,
funct: Callable[..., Coroutine[Any, Any, T]],
*args: tuple[Any],
**kwargs: dict[str, Any],
) -> asyncio.TimerHandle:
"""Start a task at the specified datetime."""
return self.coresys.call_at(when, funct, *args, **kwargs)

View File

@@ -304,16 +304,6 @@ class HostLogError(HostError):
class APIError(HassioError, RuntimeError):
"""API errors."""
def __init__(
self,
message: str | None = None,
logger: Callable[..., None] | None = None,
job_id: str | None = None,
) -> None:
"""Raise & log, optionally with job."""
super().__init__(message, logger)
self.job_id = job_id
class APIForbidden(APIError):
"""API forbidden error."""

View File

@@ -107,10 +107,7 @@ class HomeAssistantAPI(CoreSysAttributes):
continue
yield resp
return
except TimeoutError:
_LOGGER.error("Timeout on call %s.", url)
break
except aiohttp.ClientError as err:
except (TimeoutError, aiohttp.ClientError) as err:
_LOGGER.error("Error on call %s: %s", url, err)
break

View File

@@ -26,7 +26,6 @@ from ..exceptions import (
HomeAssistantWSError,
HomeAssistantWSNotSupported,
)
from ..utils.json import json_dumps
from .const import CLOSING_STATES, WSEvent, WSType
MIN_VERSION = {
@@ -75,7 +74,7 @@ class WSClient:
self._message_id += 1
_LOGGER.debug("Sending: %s", message)
try:
await self._client.send_json(message, dumps=json_dumps)
await self._client.send_json(message)
except ConnectionError as err:
raise HomeAssistantWSConnectionError(err) from err
@@ -86,7 +85,7 @@ class WSClient:
self._futures[message["id"]] = self._loop.create_future()
_LOGGER.debug("Sending: %s", message)
try:
await self._client.send_json(message, dumps=json_dumps)
await self._client.send_json(message)
except ConnectionError as err:
raise HomeAssistantWSConnectionError(err) from err
@@ -164,9 +163,7 @@ class WSClient:
hello_message = await client.receive_json()
await client.send_json(
{ATTR_TYPE: WSType.AUTH, ATTR_ACCESS_TOKEN: token}, dumps=json_dumps
)
await client.send_json({ATTR_TYPE: WSType.AUTH, ATTR_ACCESS_TOKEN: token})
auth_ok_message = await client.receive_json()

View File

@@ -1,11 +1,7 @@
"""Supervisor job manager."""
import asyncio
from collections.abc import Awaitable, Callable
from collections.abc import Callable
from contextlib import contextmanager
from contextvars import Context, ContextVar, Token
from dataclasses import dataclass
from datetime import datetime
import logging
from typing import Any
from uuid import UUID, uuid4
@@ -14,9 +10,8 @@ from attrs import Attribute, define, field
from attrs.setters import convert as attr_convert, frozen, validate as attr_validate
from attrs.validators import ge, le
from ..const import BusEvent
from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import HassioError, JobNotFound, JobStartException
from ..exceptions import JobNotFound, JobStartException
from ..homeassistant.const import WSEvent
from ..utils.common import FileConfiguration
from ..utils.sentry import capture_exception
@@ -32,14 +27,6 @@ _CURRENT_JOB: ContextVar[UUID] = ContextVar("current_job")
_LOGGER: logging.Logger = logging.getLogger(__name__)
@dataclass
class JobSchedulerOptions:
"""Options for scheduling a job."""
start_at: datetime | None = None
delayed_start: float = 0 # Ignored if start_at is set
def _remove_current_job(context: Context) -> Context:
"""Remove the current job from the context."""
context.run(_CURRENT_JOB.set, None)
@@ -61,29 +48,11 @@ def _on_change(instance: "SupervisorJob", attribute: Attribute, value: Any) -> A
return value
def _invalid_if_started(instance: "SupervisorJob", *_) -> None:
"""Validate that job has not been started."""
if instance.done is not None:
raise ValueError("Field cannot be updated once job has started")
@define
class SupervisorJobError:
"""Representation of an error occurring during a supervisor job."""
type_: type[HassioError] = HassioError
message: str = "Unknown error, see supervisor logs"
def as_dict(self) -> dict[str, str]:
"""Return dictionary representation."""
return {"type": self.type_.__name__, "message": self.message}
@define
class SupervisorJob:
"""Representation of a job running in supervisor."""
name: str | None = field(default=None, validator=[_invalid_if_started])
name: str = field(on_setattr=frozen)
reference: str | None = field(default=None, on_setattr=_on_change)
progress: float = field(
default=0,
@@ -96,17 +65,13 @@ class SupervisorJob:
)
uuid: UUID = field(init=False, factory=lambda: uuid4().hex, on_setattr=frozen)
parent_id: UUID | None = field(
factory=lambda: _CURRENT_JOB.get(None), on_setattr=frozen
init=False, factory=lambda: _CURRENT_JOB.get(None), on_setattr=frozen
)
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)
errors: list[SupervisorJobError] = field(
init=False, factory=list, on_setattr=_on_change
)
release_event: asyncio.Event | None = None
internal: bool = field(default=False, on_setattr=frozen)
def as_dict(self) -> dict[str, Any]:
"""Return dictionary representation."""
@@ -118,17 +83,8 @@ class SupervisorJob:
"stage": self.stage,
"done": self.done,
"parent_id": self.parent_id,
"errors": [err.as_dict() for err in self.errors],
}
def capture_error(self, err: HassioError | None = None) -> None:
"""Capture an error or record that an unknown error has occurred."""
if err:
new_error = SupervisorJobError(type(err), str(err))
else:
new_error = SupervisorJobError()
self.errors += [new_error]
@contextmanager
def start(self):
"""Start the job in the current task.
@@ -200,27 +156,17 @@ class JobManager(FileConfiguration, CoreSysAttributes):
def _notify_on_job_change(
self, job: SupervisorJob, attribute: Attribute, value: Any
) -> None:
"""Notify Home Assistant of a change to a job and bus on job start/end."""
if attribute.name == "errors":
value = [err.as_dict() for err in value]
"""Notify Home Assistant of a change to a job."""
self.sys_homeassistant.websocket.supervisor_event(
WSEvent.JOB, job.as_dict() | {attribute.name: value}
WSEvent.JOB, job.as_dict() | {attribute.alias: value}
)
if attribute.name == "done":
if value is False:
self.sys_bus.fire_event(BusEvent.SUPERVISOR_JOB_START, job.uuid)
if value is True:
self.sys_bus.fire_event(BusEvent.SUPERVISOR_JOB_END, job.uuid)
def new_job(
self,
name: str | None = None,
name: str,
reference: str | None = None,
initial_stage: str | None = None,
internal: bool = False,
no_parent: bool = False,
) -> SupervisorJob:
"""Create a new job."""
job = SupervisorJob(
@@ -229,7 +175,6 @@ class JobManager(FileConfiguration, CoreSysAttributes):
stage=initial_stage,
on_change=None if internal else self._notify_on_job_change,
internal=internal,
**({"parent_id": None} if no_parent else {}),
)
self._jobs[job.uuid] = job
return job
@@ -249,30 +194,3 @@ class JobManager(FileConfiguration, CoreSysAttributes):
_LOGGER.warning("Removing incomplete job %s from job manager", job.name)
del self._jobs[job.uuid]
# Clean up any completed sub jobs of this one
for sub_job in self.jobs:
if sub_job.parent_id == job.uuid and job.done:
self.remove_job(sub_job)
def schedule_job(
self,
job_method: Callable[..., Awaitable[Any]],
options: JobSchedulerOptions,
*args,
**kwargs,
) -> tuple[SupervisorJob, asyncio.Task | asyncio.TimerHandle]:
"""Schedule a job to run later and return job and task or timer handle."""
job = self.new_job(no_parent=True)
def _wrap_task() -> asyncio.Task:
return self.sys_create_task(
job_method(*args, _job__use_existing=job, **kwargs)
)
if options.start_at:
return (job, self.sys_call_at(options.start_at, _wrap_task))
if options.delayed_start:
return (job, self.sys_call_later(options.delayed_start, _wrap_task))
return (job, _wrap_task())

View File

@@ -9,7 +9,6 @@ FILE_CONFIG_JOBS = Path(SUPERVISOR_DATA, "jobs.json")
ATTR_IGNORE_CONDITIONS = "ignore_conditions"
JOB_GROUP_ADDON = "addon_{slug}"
JOB_GROUP_BACKUP = "backup_{slug}"
JOB_GROUP_BACKUP_MANAGER = "backup_manager"
JOB_GROUP_DOCKER_INTERFACE = "container_{name}"
JOB_GROUP_HOME_ASSISTANT_CORE = "home_assistant_core"

View File

@@ -6,7 +6,6 @@ from functools import wraps
import logging
from typing import Any
from . import SupervisorJob
from ..const import CoreState
from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import (
@@ -158,23 +157,22 @@ class Job(CoreSysAttributes):
self._lock = asyncio.Semaphore()
# Job groups
try:
is_job_group = obj.acquire and obj.release
except AttributeError:
is_job_group = False
if not is_job_group and self.limit in (
if self.limit in (
JobExecutionLimit.GROUP_ONCE,
JobExecutionLimit.GROUP_WAIT,
JobExecutionLimit.GROUP_THROTTLE,
JobExecutionLimit.GROUP_THROTTLE_WAIT,
JobExecutionLimit.GROUP_THROTTLE_RATE_LIMIT,
):
raise RuntimeError(
f"Job on {self.name} need to be a JobGroup to use group based limits!"
) from None
try:
_ = obj.acquire and obj.release
except AttributeError:
raise RuntimeError(
f"Job on {self.name} need to be a JobGroup to use group based limits!"
) from None
return obj if is_job_group else None
return obj
return None
def _handle_job_condition_exception(self, err: JobConditionException) -> None:
"""Handle a job condition failure."""
@@ -189,13 +187,7 @@ class Job(CoreSysAttributes):
self._method = method
@wraps(method)
async def wrapper(
obj: JobGroup | CoreSysAttributes,
*args,
_job__use_existing: SupervisorJob | None = None,
_job_override__cleanup: bool | None = None,
**kwargs,
) -> Any:
async def wrapper(obj: JobGroup | CoreSysAttributes, *args, **kwargs) -> Any:
"""Wrap the method.
This method must be on an instance of CoreSysAttributes. If a JOB_GROUP limit
@@ -203,18 +195,11 @@ class Job(CoreSysAttributes):
"""
job_group = self._post_init(obj)
group_name: str | None = job_group.group_name if job_group else None
if _job__use_existing:
job = _job__use_existing
job.name = self.name
job.internal = self._internal
if job_group:
job.reference = job_group.job_reference
else:
job = self.sys_jobs.new_job(
self.name,
job_group.job_reference if job_group else None,
internal=self._internal,
)
job = self.sys_jobs.new_job(
self.name,
job_group.job_reference if job_group else None,
internal=self._internal,
)
try:
# Handle condition
@@ -308,11 +293,9 @@ class Job(CoreSysAttributes):
except JobConditionException as err:
return self._handle_job_condition_exception(err)
except HassioError as err:
job.capture_error(err)
raise err
except Exception as err:
_LOGGER.exception("Unhandled exception: %s", err)
job.capture_error()
capture_exception(err)
raise JobException() from err
finally:
@@ -325,12 +308,7 @@ class Job(CoreSysAttributes):
# Jobs that weren't started are always cleaned up. Also clean up done jobs if required
finally:
if (
job.done is None
or _job_override__cleanup
or _job_override__cleanup is None
and self.cleanup
):
if job.done is None or self.cleanup:
self.sys_jobs.remove_job(job)
return wrapper

View File

@@ -74,7 +74,7 @@ class Scheduler(CoreSysAttributes):
def _schedule_task(self, task: _Task) -> None:
"""Schedule a task on loop."""
if isinstance(task.interval, (int, float)):
task.next = self.sys_call_later(task.interval, self._run_task, task)
task.next = self.sys_loop.call_later(task.interval, self._run_task, task)
elif isinstance(task.interval, time):
today = datetime.combine(date.today(), task.interval)
tomorrow = datetime.combine(date.today() + timedelta(days=1), task.interval)
@@ -85,7 +85,7 @@ class Scheduler(CoreSysAttributes):
else:
calc = tomorrow
task.next = self.sys_call_at(calc, self._run_task, task)
task.next = self.sys_loop.call_at(calc.timestamp(), self._run_task, task)
else:
_LOGGER.critical(
"Unknown interval %s (type: %s) for scheduler %s",

View File

@@ -1,7 +1,6 @@
"""A collection of tasks."""
import asyncio
from collections.abc import Awaitable
from datetime import timedelta
import logging
from ..addons.const import ADDON_UPDATE_CONDITIONS
@@ -11,14 +10,12 @@ from ..exceptions import AddonsError, HomeAssistantError, ObserverError
from ..homeassistant.const import LANDINGPAGE
from ..jobs.decorator import Job, JobCondition
from ..plugins.const import PLUGIN_UPDATE_CONDITIONS
from ..utils.dt import utcnow
from ..utils.sentry import capture_exception
_LOGGER: logging.Logger = logging.getLogger(__name__)
HASS_WATCHDOG_API_FAILURES = "HASS_WATCHDOG_API_FAILURES"
HASS_WATCHDOG_API = "HASS_WATCHDOG_API"
HASS_WATCHDOG_REANIMATE_FAILURES = "HASS_WATCHDOG_REANIMATE_FAILURES"
HASS_WATCHDOG_MAX_API_ATTEMPTS = 2
HASS_WATCHDOG_MAX_REANIMATE_ATTEMPTS = 5
RUN_UPDATE_SUPERVISOR = 29100
@@ -98,16 +95,6 @@ class Tasks(CoreSysAttributes):
# Evaluate available updates
if not addon.need_update:
continue
if not addon.auto_update_available:
_LOGGER.debug(
"Not updating add-on %s from %s to %s as that would cross a known breaking version",
addon.slug,
addon.version,
addon.latest_version,
)
# Delay auto-updates for a day in case of issues
if utcnow() + timedelta(days=1) > addon.latest_version_timestamp:
continue
if not addon.test_update_schema():
_LOGGER.warning(
"Add-on %s will be ignored, schema tests failed", addon.slug
@@ -170,7 +157,6 @@ class Tasks(CoreSysAttributes):
if await self.sys_homeassistant.api.check_api_state():
# Home Assistant is running properly
self._cache[HASS_WATCHDOG_REANIMATE_FAILURES] = 0
self._cache[HASS_WATCHDOG_API_FAILURES] = 0
return
# Give up after 5 reanimation failures in a row. Supervisor cannot fix this issue.
@@ -178,26 +164,23 @@ class Tasks(CoreSysAttributes):
if reanimate_fails >= HASS_WATCHDOG_MAX_REANIMATE_ATTEMPTS:
if reanimate_fails == HASS_WATCHDOG_MAX_REANIMATE_ATTEMPTS:
_LOGGER.critical(
"Watchdog cannot reanimate Home Assistant Core, failed all %s attempts.",
"Watchdog cannot reanimate Home Assistant, failed all %s attempts.",
reanimate_fails,
)
self._cache[HASS_WATCHDOG_REANIMATE_FAILURES] += 1
return
# Init cache data
api_fails = self._cache.get(HASS_WATCHDOG_API_FAILURES, 0)
retry_scan = self._cache.get(HASS_WATCHDOG_API, 0)
# Look like we run into a problem
api_fails += 1
if api_fails < HASS_WATCHDOG_MAX_API_ATTEMPTS:
self._cache[HASS_WATCHDOG_API_FAILURES] = api_fails
_LOGGER.warning("Watchdog missed an Home Assistant Core API response.")
retry_scan += 1
if retry_scan == 1:
self._cache[HASS_WATCHDOG_API] = retry_scan
_LOGGER.warning("Watchdog miss API response from Home Assistant")
return
_LOGGER.error(
"Watchdog missed %s Home Assistant Core API responses in a row. Restarting Home Assistant Core API!",
HASS_WATCHDOG_MAX_API_ATTEMPTS,
)
_LOGGER.error("Watchdog found a problem with Home Assistant API!")
try:
await self.sys_homeassistant.core.restart()
except HomeAssistantError as err:
@@ -208,7 +191,7 @@ class Tasks(CoreSysAttributes):
else:
self._cache[HASS_WATCHDOG_REANIMATE_FAILURES] = 0
finally:
self._cache[HASS_WATCHDOG_API_FAILURES] = 0
self._cache[HASS_WATCHDOG_API] = 0
@Job(name="tasks_update_cli", conditions=PLUGIN_AUTO_UPDATE_CONDITIONS)
async def _update_cli(self):

View File

@@ -207,7 +207,6 @@ class StoreManager(CoreSysAttributes, FileConfiguration):
await self.data.update()
self._read_addons()
@Job(name="store_manager_update_repositories")
async def update_repositories(
self,
list_repositories: list[str],

View File

@@ -14,8 +14,6 @@ from ..const import (
ATTR_REPOSITORY,
ATTR_SLUG,
ATTR_TRANSLATIONS,
ATTR_VERSION,
ATTR_VERSION_TIMESTAMP,
FILE_SUFFIX_CONFIGURATION,
REPOSITORY_CORE,
REPOSITORY_LOCAL,
@@ -24,7 +22,6 @@ from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import ConfigurationFileError
from ..resolution.const import ContextType, IssueType, SuggestionType, UnhealthyReason
from ..utils.common import find_one_filetype, read_json_or_yaml_file
from ..utils.dt import utcnow
from ..utils.json import read_json_file
from .const import StoreType
from .utils import extract_hash_from_path
@@ -138,19 +135,6 @@ class StoreData(CoreSysAttributes):
repositories[repo.slug] = repo.config
addons.update(await self._read_addons_folder(repo.path, repo.slug))
# Add a timestamp when we first see a new version
for slug, config in addons.items():
old_config = self.addons.get(slug)
if (
not old_config
or ATTR_VERSION_TIMESTAMP not in old_config
or old_config.get(ATTR_VERSION) != config.get(ATTR_VERSION)
):
config[ATTR_VERSION_TIMESTAMP] = utcnow().timestamp()
else:
config[ATTR_VERSION_TIMESTAMP] = old_config[ATTR_VERSION_TIMESTAMP]
self.repositories = repositories
self.addons = addons

View File

@@ -6,7 +6,6 @@ import errno
from pathlib import Path
from unittest.mock import MagicMock, PropertyMock, patch
from awesomeversion import AwesomeVersion
from docker.errors import DockerException, NotFound
import pytest
from securetar import SecureTarFile
@@ -722,29 +721,3 @@ def test_addon_pulse_error(
assert "can't write pulse/client.config" in caplog.text
assert coresys.core.healthy is False
def test_auto_update_available(coresys: CoreSys, install_addon_example: Addon):
"""Test auto update availability based on versions."""
assert install_addon_example.auto_update is False
assert install_addon_example.need_update is False
assert install_addon_example.auto_update_available is False
with patch.object(
Addon, "version", new=PropertyMock(return_value=AwesomeVersion("1.0"))
):
assert install_addon_example.need_update is True
assert install_addon_example.auto_update_available is False
install_addon_example.auto_update = True
assert install_addon_example.auto_update_available is True
with patch.object(
Addon, "version", new=PropertyMock(return_value=AwesomeVersion("0.9"))
):
assert install_addon_example.auto_update_available is False
with patch.object(
Addon, "version", new=PropertyMock(return_value=AwesomeVersion("test"))
):
assert install_addon_example.auto_update_available is False

View File

@@ -2,22 +2,17 @@
import asyncio
from pathlib import Path, PurePath
from typing import Any
from unittest.mock import ANY, AsyncMock, PropertyMock, patch
from unittest.mock import ANY, AsyncMock, patch
from aiohttp.test_utils import TestClient
from awesomeversion import AwesomeVersion
import pytest
from supervisor.addons.addon import Addon
from supervisor.backups.backup import Backup
from supervisor.const import CoreState
from supervisor.coresys import CoreSys
from supervisor.exceptions import AddonsError, HomeAssistantBackupError
from supervisor.homeassistant.core import HomeAssistantCore
from supervisor.homeassistant.module import HomeAssistant
from supervisor.mounts.mount import Mount
from supervisor.supervisor import Supervisor
async def test_info(api_client, coresys: CoreSys, mock_full_backup: Backup):
@@ -204,256 +199,3 @@ async def test_api_backup_exclude_database(
backup.assert_awaited_once_with(ANY, True)
assert resp.status == 200
async def _get_job_info(api_client: TestClient, job_id: str) -> dict[str, Any]:
"""Test background job progress and block until it is done."""
resp = await api_client.get(f"/jobs/{job_id}")
assert resp.status == 200
result = await resp.json()
return result["data"]
@pytest.mark.parametrize(
"backup_type,options",
[
("full", {}),
(
"partial",
{
"homeassistant": True,
"folders": ["addons/local", "media", "share", "ssl"],
},
),
],
)
async def test_api_backup_restore_background(
api_client: TestClient,
coresys: CoreSys,
backup_type: str,
options: dict[str, Any],
tmp_supervisor_data: Path,
path_extern,
):
"""Test background option on backup/restore APIs."""
coresys.core.state = CoreState.RUNNING
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
coresys.homeassistant.version = AwesomeVersion("2023.09.0")
(tmp_supervisor_data / "addons/local").mkdir(parents=True)
assert coresys.jobs.jobs == []
resp = await api_client.post(
f"/backups/new/{backup_type}",
json={"background": True, "name": f"{backup_type} backup"} | options,
)
assert resp.status == 200
result = await resp.json()
job_id = result["data"]["job_id"]
assert (await _get_job_info(api_client, job_id))["done"] is False
while not (job := (await _get_job_info(api_client, job_id)))["done"]:
await asyncio.sleep(0)
assert job["name"] == f"backup_manager_{backup_type}_backup"
assert (backup_slug := job["reference"])
assert job["child_jobs"][0]["name"] == "backup_store_homeassistant"
assert job["child_jobs"][0]["reference"] == backup_slug
assert job["child_jobs"][1]["name"] == "backup_store_folders"
assert job["child_jobs"][1]["reference"] == backup_slug
assert {j["reference"] for j in job["child_jobs"][1]["child_jobs"]} == {
"addons/local",
"media",
"share",
"ssl",
}
with patch.object(HomeAssistantCore, "start"):
resp = await api_client.post(
f"/backups/{backup_slug}/restore/{backup_type}",
json={"background": True} | options,
)
assert resp.status == 200
result = await resp.json()
job_id = result["data"]["job_id"]
assert (await _get_job_info(api_client, job_id))["done"] is False
while not (job := (await _get_job_info(api_client, job_id)))["done"]:
await asyncio.sleep(0)
assert job["name"] == f"backup_manager_{backup_type}_restore"
assert job["reference"] == backup_slug
assert job["child_jobs"][0]["name"] == "backup_restore_folders"
assert job["child_jobs"][0]["reference"] == backup_slug
assert {j["reference"] for j in job["child_jobs"][0]["child_jobs"]} == {
"addons/local",
"media",
"share",
"ssl",
}
assert job["child_jobs"][1]["name"] == "backup_restore_homeassistant"
assert job["child_jobs"][1]["reference"] == backup_slug
if backup_type == "full":
assert job["child_jobs"][2]["name"] == "backup_remove_delta_addons"
assert job["child_jobs"][2]["reference"] == backup_slug
@pytest.mark.parametrize(
"backup_type,options",
[
("full", {}),
(
"partial",
{
"homeassistant": True,
"folders": ["addons/local", "media", "share", "ssl"],
"addons": ["local_ssh"],
},
),
],
)
async def test_api_backup_errors(
api_client: TestClient,
coresys: CoreSys,
backup_type: str,
options: dict[str, Any],
tmp_supervisor_data: Path,
install_addon_ssh,
path_extern,
):
"""Test error reporting in backup job."""
coresys.core.state = CoreState.RUNNING
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
coresys.homeassistant.version = AwesomeVersion("2023.09.0")
(tmp_supervisor_data / "addons/local").mkdir(parents=True)
assert coresys.jobs.jobs == []
with patch.object(Addon, "backup", side_effect=AddonsError("Backup error")):
resp = await api_client.post(
f"/backups/new/{backup_type}",
json={"name": f"{backup_type} backup"} | options,
)
assert resp.status == 200
result = await resp.json()
job_id = result["data"]["job_id"]
slug = result["data"]["slug"]
job = await _get_job_info(api_client, job_id)
assert job["name"] == f"backup_manager_{backup_type}_backup"
assert job["done"] is True
assert job["reference"] == slug
assert job["errors"] == []
assert job["child_jobs"][0]["name"] == "backup_store_addons"
assert job["child_jobs"][0]["reference"] == slug
assert job["child_jobs"][0]["child_jobs"][0]["name"] == "backup_addon_save"
assert job["child_jobs"][0]["child_jobs"][0]["reference"] == "local_ssh"
assert job["child_jobs"][0]["child_jobs"][0]["errors"] == [
{"type": "BackupError", "message": "Can't create backup for local_ssh"}
]
assert job["child_jobs"][1]["name"] == "backup_store_homeassistant"
assert job["child_jobs"][1]["reference"] == slug
assert job["child_jobs"][2]["name"] == "backup_store_folders"
assert job["child_jobs"][2]["reference"] == slug
assert {j["reference"] for j in job["child_jobs"][2]["child_jobs"]} == {
"addons/local",
"media",
"share",
"ssl",
}
with patch.object(
HomeAssistant, "backup", side_effect=HomeAssistantBackupError("Backup error")
), patch.object(Addon, "backup"):
resp = await api_client.post(
f"/backups/new/{backup_type}",
json={"name": f"{backup_type} backup"} | options,
)
assert resp.status == 400
result = await resp.json()
job_id = result["job_id"]
job = await _get_job_info(api_client, job_id)
assert job["name"] == f"backup_manager_{backup_type}_backup"
assert job["done"] is True
assert job["errors"] == (
err := [{"type": "HomeAssistantBackupError", "message": "Backup error"}]
)
assert job["child_jobs"][0]["name"] == "backup_store_addons"
assert job["child_jobs"][1]["name"] == "backup_store_homeassistant"
assert job["child_jobs"][1]["errors"] == err
assert len(job["child_jobs"]) == 2
async def test_backup_immediate_errors(api_client: TestClient, coresys: CoreSys):
"""Test backup errors that return immediately even in background mode."""
coresys.core.state = CoreState.FREEZE
resp = await api_client.post(
"/backups/new/full",
json={"name": "Test", "background": True},
)
assert resp.status == 400
assert "freeze" in (await resp.json())["message"]
coresys.core.state = CoreState.RUNNING
coresys.hardware.disk.get_disk_free_space = lambda x: 0.5
resp = await api_client.post(
"/backups/new/partial",
json={"name": "Test", "homeassistant": True, "background": True},
)
assert resp.status == 400
assert "not enough free space" in (await resp.json())["message"]
async def test_restore_immediate_errors(
request: pytest.FixtureRequest,
api_client: TestClient,
coresys: CoreSys,
mock_partial_backup: Backup,
):
"""Test restore errors that return immediately even in background mode."""
coresys.core.state = CoreState.RUNNING
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
resp = await api_client.post(
f"/backups/{mock_partial_backup.slug}/restore/full", json={"background": True}
)
assert resp.status == 400
assert "only a partial backup" in (await resp.json())["message"]
with patch.object(
Backup,
"supervisor_version",
new=PropertyMock(return_value=AwesomeVersion("2024.01.0")),
), patch.object(
Supervisor,
"version",
new=PropertyMock(return_value=AwesomeVersion("2023.12.0")),
):
resp = await api_client.post(
f"/backups/{mock_partial_backup.slug}/restore/partial",
json={"background": True, "homeassistant": True},
)
assert resp.status == 400
assert "Must update supervisor" in (await resp.json())["message"]
with patch.object(
Backup, "protected", new=PropertyMock(return_value=True)
), patch.object(Backup, "set_password", return_value=False):
resp = await api_client.post(
f"/backups/{mock_partial_backup.slug}/restore/partial",
json={"background": True, "homeassistant": True},
)
assert resp.status == 400
assert "Invalid password" in (await resp.json())["message"]
with patch.object(Backup, "homeassistant", new=PropertyMock(return_value=None)):
resp = await api_client.post(
f"/backups/{mock_partial_backup.slug}/restore/partial",
json={"background": True, "homeassistant": True},
)
assert resp.status == 400
assert "No Home Assistant" in (await resp.json())["message"]

View File

@@ -107,7 +107,6 @@ async def test_jobs_tree_representation(api_client: TestClient, coresys: CoreSys
"progress": 50,
"stage": None,
"done": False,
"errors": [],
"child_jobs": [
{
"name": "test_jobs_tree_inner",
@@ -117,7 +116,6 @@ async def test_jobs_tree_representation(api_client: TestClient, coresys: CoreSys
"stage": None,
"done": False,
"child_jobs": [],
"errors": [],
},
],
},
@@ -129,7 +127,6 @@ async def test_jobs_tree_representation(api_client: TestClient, coresys: CoreSys
"stage": "init",
"done": False,
"child_jobs": [],
"errors": [],
},
]
@@ -147,70 +144,5 @@ async def test_jobs_tree_representation(api_client: TestClient, coresys: CoreSys
"stage": "end",
"done": True,
"child_jobs": [],
"errors": [],
},
]
async def test_job_manual_cleanup(api_client: TestClient, coresys: CoreSys):
"""Test manually cleaning up a job via API."""
class TestClass:
"""Test class."""
def __init__(self, coresys: CoreSys):
"""Initialize the test class."""
self.coresys = coresys
self.event = asyncio.Event()
self.job_id: str | None = None
@Job(name="test_job_manual_cleanup", cleanup=False)
async def test_job_manual_cleanup(self) -> None:
"""Job that requires manual cleanup."""
self.job_id = coresys.jobs.current.uuid
await self.event.wait()
test = TestClass(coresys)
task = asyncio.create_task(test.test_job_manual_cleanup())
await asyncio.sleep(0)
# Check the job details
resp = await api_client.get(f"/jobs/{test.job_id}")
assert resp.status == 200
result = await resp.json()
assert result["data"] == {
"name": "test_job_manual_cleanup",
"reference": None,
"uuid": test.job_id,
"progress": 0,
"stage": None,
"done": False,
"child_jobs": [],
"errors": [],
}
# Only done jobs can be deleted via API
resp = await api_client.delete(f"/jobs/{test.job_id}")
assert resp.status == 400
result = await resp.json()
assert result["message"] == f"Job {test.job_id} is not done!"
# Let the job finish
test.event.set()
await task
# Check that it is now done
resp = await api_client.get(f"/jobs/{test.job_id}")
assert resp.status == 200
result = await resp.json()
assert result["data"]["done"] is True
# Delete it
resp = await api_client.delete(f"/jobs/{test.job_id}")
assert resp.status == 200
# Confirm it no longer exists
resp = await api_client.get(f"/jobs/{test.job_id}")
assert resp.status == 400
result = await resp.json()
assert result["message"] == f"No job found with id {test.job_id}"

View File

@@ -25,7 +25,6 @@ def fixture_backup_mock():
backup_instance.restore_homeassistant = AsyncMock(return_value=None)
backup_instance.restore_addons = AsyncMock(return_value=(True, []))
backup_instance.restore_repositories = AsyncMock(return_value=None)
backup_instance.remove_delta_addons = AsyncMock(return_value=True)
yield backup_mock

View File

@@ -10,8 +10,8 @@ from supervisor.coresys import CoreSys
async def test_new_backup_stays_in_folder(coresys: CoreSys, tmp_path: Path):
"""Test making a new backup operates entirely within folder where backup will be stored."""
backup = Backup(coresys, tmp_path / "my_backup.tar", "test")
backup.new("test", "2023-07-21T21:05:00.000000+00:00", BackupType.FULL)
backup = Backup(coresys, tmp_path / "my_backup.tar")
backup.new("test", "test", "2023-07-21T21:05:00.000000+00:00", BackupType.FULL)
assert not listdir(tmp_path)
async with backup:

View File

@@ -2,7 +2,6 @@
import asyncio
import errno
from functools import partial
from pathlib import Path
from shutil import rmtree
from unittest.mock import ANY, AsyncMock, MagicMock, Mock, PropertyMock, patch
@@ -24,6 +23,7 @@ from supervisor.docker.const import ContainerState
from supervisor.docker.homeassistant import DockerHomeAssistant
from supervisor.docker.monitor import DockerContainerStateEvent
from supervisor.exceptions import (
AddonsError,
BackupError,
BackupInvalidError,
BackupJobError,
@@ -53,9 +53,9 @@ async def test_do_backup_full(coresys: CoreSys, backup_mock, install_addon_ssh):
backup_instance: MagicMock = await manager.do_backup_full()
# Check Backup has been created without password
assert backup_instance.new.call_args[0][2] == BackupType.FULL
assert backup_instance.new.call_args[0][3] is None
assert backup_instance.new.call_args[0][4] is True
assert backup_instance.new.call_args[0][3] == BackupType.FULL
assert backup_instance.new.call_args[0][4] is None
assert backup_instance.new.call_args[0][5] is True
backup_instance.store_homeassistant.assert_called_once()
backup_instance.store_repositories.assert_called_once()
@@ -83,9 +83,9 @@ async def test_do_backup_full_uncompressed(
backup_instance: MagicMock = await manager.do_backup_full(compressed=False)
# Check Backup has been created without password
assert backup_instance.new.call_args[0][2] == BackupType.FULL
assert backup_instance.new.call_args[0][3] is None
assert backup_instance.new.call_args[0][4] is False
assert backup_instance.new.call_args[0][3] == BackupType.FULL
assert backup_instance.new.call_args[0][4] is None
assert backup_instance.new.call_args[0][5] is False
backup_instance.store_homeassistant.assert_called_once()
backup_instance.store_repositories.assert_called_once()
@@ -114,9 +114,9 @@ async def test_do_backup_partial_minimal(
backup_instance: MagicMock = await manager.do_backup_partial(homeassistant=False)
# Check Backup has been created without password
assert backup_instance.new.call_args[0][2] == BackupType.PARTIAL
assert backup_instance.new.call_args[0][3] is None
assert backup_instance.new.call_args[0][4] is True
assert backup_instance.new.call_args[0][3] == BackupType.PARTIAL
assert backup_instance.new.call_args[0][4] is None
assert backup_instance.new.call_args[0][5] is True
backup_instance.store_homeassistant.assert_not_called()
backup_instance.store_repositories.assert_called_once()
@@ -144,9 +144,9 @@ async def test_do_backup_partial_minimal_uncompressed(
)
# Check Backup has been created without password
assert backup_instance.new.call_args[0][2] == BackupType.PARTIAL
assert backup_instance.new.call_args[0][3] is None
assert backup_instance.new.call_args[0][4] is False
assert backup_instance.new.call_args[0][3] == BackupType.PARTIAL
assert backup_instance.new.call_args[0][4] is None
assert backup_instance.new.call_args[0][5] is False
backup_instance.store_homeassistant.assert_not_called()
backup_instance.store_repositories.assert_called_once()
@@ -176,9 +176,9 @@ async def test_do_backup_partial_maximal(
)
# Check Backup has been created without password
assert backup_instance.new.call_args[0][2] == BackupType.PARTIAL
assert backup_instance.new.call_args[0][3] is None
assert backup_instance.new.call_args[0][4] is True
assert backup_instance.new.call_args[0][3] == BackupType.PARTIAL
assert backup_instance.new.call_args[0][4] is None
assert backup_instance.new.call_args[0][5] is True
backup_instance.store_homeassistant.assert_called_once()
backup_instance.store_repositories.assert_called_once()
@@ -206,10 +206,6 @@ async def test_do_restore_full(coresys: CoreSys, full_backup_mock, install_addon
manager = BackupManager(coresys)
backup_instance = full_backup_mock.return_value
backup_instance.sys_addons = coresys.addons
backup_instance.remove_delta_addons = partial(
Backup.remove_delta_addons, backup_instance
)
assert await manager.do_restore_full(backup_instance)
backup_instance.restore_homeassistant.assert_called_once()
@@ -239,10 +235,6 @@ async def test_do_restore_full_different_addon(
backup_instance = full_backup_mock.return_value
backup_instance.addon_list = ["differentslug"]
backup_instance.sys_addons = coresys.addons
backup_instance.remove_delta_addons = partial(
Backup.remove_delta_addons, backup_instance
)
assert await manager.do_restore_full(backup_instance)
backup_instance.restore_homeassistant.assert_called_once()
@@ -379,7 +371,7 @@ async def test_backup_error(
coresys.core.state = CoreState.RUNNING
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
backup_mock.return_value.store_folders.side_effect = (err := OSError())
backup_mock.return_value.store_addons.side_effect = (err := AddonsError())
await coresys.backups.do_backup_full()
capture_exception.assert_called_once_with(err)
@@ -945,7 +937,6 @@ def _make_backup_message_for_assert(
"stage": stage,
"done": done,
"parent_id": None,
"errors": [],
},
},
}

View File

@@ -395,7 +395,6 @@ async def tmp_supervisor_data(coresys: CoreSys, tmp_path: Path) -> Path:
coresys.config.path_share.mkdir()
coresys.config.path_addons_data.mkdir(parents=True)
coresys.config.path_addon_configs.mkdir(parents=True)
coresys.config.path_ssl.mkdir()
yield tmp_path
@@ -539,8 +538,8 @@ def install_addon_example(coresys: CoreSys, repository):
@pytest.fixture
async def mock_full_backup(coresys: CoreSys, tmp_path) -> Backup:
"""Mock a full backup."""
mock_backup = Backup(coresys, Path(tmp_path, "test_backup"), "test")
mock_backup.new("Test", utcnow().isoformat(), BackupType.FULL)
mock_backup = Backup(coresys, Path(tmp_path, "test_backup"))
mock_backup.new("test", "Test", utcnow().isoformat(), BackupType.FULL)
mock_backup.repositories = ["https://github.com/awesome-developer/awesome-repo"]
mock_backup.docker = {}
mock_backup._data[ATTR_ADDONS] = [
@@ -563,8 +562,8 @@ async def mock_full_backup(coresys: CoreSys, tmp_path) -> Backup:
@pytest.fixture
async def mock_partial_backup(coresys: CoreSys, tmp_path) -> Backup:
"""Mock a partial backup."""
mock_backup = Backup(coresys, Path(tmp_path, "test_backup"), "test")
mock_backup.new("Test", utcnow().isoformat(), BackupType.PARTIAL)
mock_backup = Backup(coresys, Path(tmp_path, "test_backup"))
mock_backup.new("test", "Test", utcnow().isoformat(), BackupType.PARTIAL)
mock_backup.repositories = ["https://github.com/awesome-developer/awesome-repo"]
mock_backup.docker = {}
mock_backup._data[ATTR_ADDONS] = [
@@ -594,7 +593,7 @@ async def backups(
temp_tar = Path(tmp_path, f"{slug}.tar")
with SecureTarFile(temp_tar, "w"):
pass
backup = Backup(coresys, temp_tar, slug)
backup = Backup(coresys, temp_tar)
backup._data = { # pylint: disable=protected-access
ATTR_SLUG: slug,
ATTR_DATE: utcnow().isoformat(),

View File

@@ -159,36 +159,23 @@ async def test_attach_existing_container(
):
await coresys.homeassistant.core.instance.attach(AwesomeVersion("2022.7.3"))
await asyncio.sleep(0)
assert [
event
for event in fire_event.call_args_list
if event.args[0] == BusEvent.DOCKER_CONTAINER_STATE_CHANGE
] == [
call(
BusEvent.DOCKER_CONTAINER_STATE_CHANGE,
DockerContainerStateEvent("homeassistant", expected, "abc123", 1),
)
]
fire_event.assert_called_once_with(
BusEvent.DOCKER_CONTAINER_STATE_CHANGE,
DockerContainerStateEvent("homeassistant", expected, "abc123", 1),
)
fire_event.reset_mock()
await coresys.homeassistant.core.instance.attach(
AwesomeVersion("2022.7.3"), skip_state_event_if_down=True
)
await asyncio.sleep(0)
docker_events = [
event
for event in fire_event.call_args_list
if event.args[0] == BusEvent.DOCKER_CONTAINER_STATE_CHANGE
]
if fired_when_skip_down:
assert docker_events == [
call(
BusEvent.DOCKER_CONTAINER_STATE_CHANGE,
DockerContainerStateEvent("homeassistant", expected, "abc123", 1),
)
]
fire_event.assert_called_once_with(
BusEvent.DOCKER_CONTAINER_STATE_CHANGE,
DockerContainerStateEvent("homeassistant", expected, "abc123", 1),
)
else:
assert not docker_events
fire_event.assert_not_called()
async def test_attach_container_failure(coresys: CoreSys):
@@ -208,11 +195,7 @@ async def test_attach_container_failure(coresys: CoreSys):
type(coresys.bus), "fire_event"
) as fire_event:
await coresys.homeassistant.core.instance.attach(AwesomeVersion("2022.7.3"))
assert not [
event
for event in fire_event.call_args_list
if event.args[0] == BusEvent.DOCKER_CONTAINER_STATE_CHANGE
]
fire_event.assert_not_called()
assert coresys.homeassistant.core.instance.meta_config == image_config

View File

@@ -20,6 +20,3 @@ schema:
message: "str?"
ingress: true
ingress_port: 0
breaking_versions:
- test
- 1.0

View File

@@ -1,7 +1,7 @@
"""Test the condition decorators."""
# pylint: disable=protected-access,import-error
import asyncio
from datetime import datetime, timedelta
from datetime import timedelta
from unittest.mock import ANY, AsyncMock, Mock, PropertyMock, patch
from uuid import uuid4
@@ -9,7 +9,7 @@ from aiohttp.client_exceptions import ClientError
import pytest
import time_machine
from supervisor.const import BusEvent, CoreState
from supervisor.const import CoreState
from supervisor.coresys import CoreSys
from supervisor.exceptions import (
AudioUpdateError,
@@ -19,7 +19,7 @@ from supervisor.exceptions import (
)
from supervisor.host.const import HostFeature
from supervisor.host.manager import HostManager
from supervisor.jobs import JobSchedulerOptions, SupervisorJob
from supervisor.jobs import SupervisorJob
from supervisor.jobs.const import JobExecutionLimit
from supervisor.jobs.decorator import Job, JobCondition
from supervisor.jobs.job_group import JobGroup
@@ -979,7 +979,6 @@ async def test_internal_jobs_no_notify(coresys: CoreSys):
"stage": None,
"done": True,
"parent_id": None,
"errors": [],
},
},
}
@@ -1096,104 +1095,3 @@ async def test_job_always_removed_on_check_failure(coresys: CoreSys):
await task
assert job.done
assert coresys.jobs.jobs == [job]
async def test_job_scheduled_delay(coresys: CoreSys):
"""Test job that schedules a job to start after delay."""
class TestClass:
"""Test class."""
def __init__(self, coresys: CoreSys) -> None:
"""Initialize object."""
self.coresys = coresys
@Job(name="test_job_scheduled_delay_job_scheduler")
async def job_scheduler(self) -> tuple[SupervisorJob, asyncio.TimerHandle]:
"""Schedule a job to run after delay."""
return self.coresys.jobs.schedule_job(
self.job_task, JobSchedulerOptions(delayed_start=0.1)
)
@Job(name="test_job_scheduled_delay_job_task")
async def job_task(self) -> None:
"""Do scheduled work."""
self.coresys.jobs.current.stage = "work"
test = TestClass(coresys)
job, _ = await test.job_scheduler()
started = False
ended = False
async def start_listener(job_id: str):
nonlocal started
started = started or job_id == job.uuid
async def end_listener(job_id: str):
nonlocal ended
ended = ended or job_id == job.uuid
coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_START, start_listener)
coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_END, end_listener)
await asyncio.sleep(0.2)
assert started
assert ended
assert job.done
assert job.name == "test_job_scheduled_delay_job_task"
assert job.stage == "work"
assert job.parent_id is None
async def test_job_scheduled_at(coresys: CoreSys):
"""Test job that schedules a job to start at a specified time."""
dt = datetime.now()
class TestClass:
"""Test class."""
def __init__(self, coresys: CoreSys) -> None:
"""Initialize object."""
self.coresys = coresys
@Job(name="test_job_scheduled_at_job_scheduler")
async def job_scheduler(self) -> tuple[SupervisorJob, asyncio.TimerHandle]:
"""Schedule a job to run at specified time."""
return self.coresys.jobs.schedule_job(
self.job_task, JobSchedulerOptions(start_at=dt + timedelta(seconds=0.1))
)
@Job(name="test_job_scheduled_at_job_task")
async def job_task(self) -> None:
"""Do scheduled work."""
self.coresys.jobs.current.stage = "work"
test = TestClass(coresys)
with time_machine.travel(dt):
job, _ = await test.job_scheduler()
started = False
ended = False
async def start_listener(job_id: str):
nonlocal started
started = started or job_id == job.uuid
async def end_listener(job_id: str):
nonlocal ended
ended = ended or job_id == job.uuid
coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_START, start_listener)
coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_END, end_listener)
await asyncio.sleep(0.2)
assert started
assert ended
assert job.done
assert job.name == "test_job_scheduled_at_job_task"
assert job.stage == "work"
assert job.parent_id is None

View File

@@ -106,7 +106,6 @@ async def test_notify_on_change(coresys: CoreSys):
"stage": None,
"done": None,
"parent_id": None,
"errors": [],
},
},
}
@@ -127,7 +126,6 @@ async def test_notify_on_change(coresys: CoreSys):
"stage": "test",
"done": None,
"parent_id": None,
"errors": [],
},
},
}
@@ -148,7 +146,6 @@ async def test_notify_on_change(coresys: CoreSys):
"stage": "test",
"done": None,
"parent_id": None,
"errors": [],
},
},
}
@@ -169,33 +166,6 @@ async def test_notify_on_change(coresys: CoreSys):
"stage": "test",
"done": False,
"parent_id": None,
"errors": [],
},
},
}
)
job.capture_error()
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,
"errors": [
{
"type": "HassioError",
"message": "Unknown error, see supervisor logs",
}
],
},
},
}
@@ -215,12 +185,6 @@ async def test_notify_on_change(coresys: CoreSys):
"stage": "test",
"done": True,
"parent_id": None,
"errors": [
{
"type": "HassioError",
"message": "Unknown error, see supervisor logs",
}
],
},
},
}

View File

@@ -34,21 +34,15 @@ async def test_watchdog_homeassistant_api(
await tasks._watchdog_homeassistant_api()
restart.assert_not_called()
assert "Watchdog missed an Home Assistant Core API response." in caplog.text
assert (
"Watchdog missed 2 Home Assistant Core API responses in a row. Restarting Home Assistant Core API!"
not in caplog.text
)
assert "Watchdog miss API response from Home Assistant" in caplog.text
assert "Watchdog found a problem with Home Assistant API!" not in caplog.text
caplog.clear()
await tasks._watchdog_homeassistant_api()
restart.assert_called_once()
assert "Watchdog missed an Home Assistant Core API response." not in caplog.text
assert (
"Watchdog missed 2 Home Assistant Core API responses in a row. Restarting Home Assistant Core API!"
in caplog.text
)
assert "Watchdog miss API response from Home Assistant" not in caplog.text
assert "Watchdog found a problem with Home Assistant API!" in caplog.text
async def test_watchdog_homeassistant_api_off(tasks: Tasks, coresys: CoreSys):
@@ -126,10 +120,10 @@ async def test_watchdog_homeassistant_api_reanimation_limit(
await tasks._watchdog_homeassistant_api()
restart.assert_not_called()
assert "Watchdog missed an Home Assistant Core API response." not in caplog.text
assert "Watchdog miss API response from Home Assistant" not in caplog.text
assert "Watchdog found a problem with Home Assistant API!" not in caplog.text
assert (
"Watchdog cannot reanimate Home Assistant Core, failed all 5 attempts."
"Watchdog cannot reanimate Home Assistant, failed all 5 attempts."
in caplog.text
)

View File

@@ -243,18 +243,3 @@ async def test_reload(coresys: CoreSys):
await coresys.store.reload()
assert git_pull.call_count == 3
async def test_addon_version_timestamp(coresys: CoreSys, install_addon_example: Addon):
"""Test timestamp tracked for addon's version."""
# When unset, version timestamp set to utcnow on store load
assert (timestamp := install_addon_example.latest_version_timestamp)
# Reload of the store does not change timestamp unless version changes
await coresys.store.reload()
assert timestamp == install_addon_example.latest_version_timestamp
# If a new version is seen processing repo, reset to utc now
install_addon_example.data_store["version"] = "1.1.0"
await coresys.store.reload()
assert timestamp < install_addon_example.latest_version_timestamp