mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-09-08 20:56:26 +00:00
Compare commits
26 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
38758d05a8 | ||
![]() |
a79fa14ee7 | ||
![]() |
1eb95b4d33 | ||
![]() |
d04e47f5b3 | ||
![]() |
dad5118f21 | ||
![]() |
acc0e5c989 | ||
![]() |
204fcdf479 | ||
![]() |
93ba8a3574 | ||
![]() |
f2f9e3b514 | ||
![]() |
61288559b3 | ||
![]() |
bd2c99a455 | ||
![]() |
1937348b24 | ||
![]() |
b7b2fae325 | ||
![]() |
11115923b2 | ||
![]() |
295133d2e9 | ||
![]() |
3018b851c8 | ||
![]() |
222c3fd485 | ||
![]() |
9650fd2ba1 | ||
![]() |
c88fd9a7d9 | ||
![]() |
1611beccd1 | ||
![]() |
71077fb0f7 | ||
![]() |
9647fba98f | ||
![]() |
86f004e45a | ||
![]() |
a98334ede8 | ||
![]() |
e19c2d6805 | ||
![]() |
847736dab8 |
12
.github/workflows/builder.yml
vendored
12
.github/workflows/builder.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
||||
requirements: ${{ steps.requirements.outputs.changed }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v3.5.3
|
||||
uses: actions/checkout@v3.6.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
arch: ${{ fromJson(needs.init.outputs.architectures) }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v3.5.3
|
||||
uses: actions/checkout@v3.6.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -160,7 +160,7 @@ jobs:
|
||||
run: echo "BUILD_ARGS=--test" >> $GITHUB_ENV
|
||||
|
||||
- name: Build supervisor
|
||||
uses: home-assistant/builder@2023.06.1
|
||||
uses: home-assistant/builder@2023.08.0
|
||||
with:
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
@@ -178,7 +178,7 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
if: needs.init.outputs.publish == 'true'
|
||||
uses: actions/checkout@v3.5.3
|
||||
uses: actions/checkout@v3.6.0
|
||||
|
||||
- name: Initialize git
|
||||
if: needs.init.outputs.publish == 'true'
|
||||
@@ -203,11 +203,11 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v3.5.3
|
||||
uses: actions/checkout@v3.6.0
|
||||
|
||||
- name: Build the Supervisor
|
||||
if: needs.init.outputs.publish != 'true'
|
||||
uses: home-assistant/builder@2023.06.1
|
||||
uses: home-assistant/builder@2023.08.0
|
||||
with:
|
||||
args: |
|
||||
--test \
|
||||
|
22
.github/workflows/ci.yaml
vendored
22
.github/workflows/ci.yaml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
name: Prepare Python dependencies
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.5.3
|
||||
uses: actions/checkout@v3.6.0
|
||||
- name: Set up Python
|
||||
id: python
|
||||
uses: actions/setup-python@v4.7.0
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.5.3
|
||||
uses: actions/checkout@v3.6.0
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v4.7.0
|
||||
id: python
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.5.3
|
||||
uses: actions/checkout@v3.6.0
|
||||
- name: Register hadolint problem matcher
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/hadolint.json"
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.5.3
|
||||
uses: actions/checkout@v3.6.0
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v4.7.0
|
||||
id: python
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.5.3
|
||||
uses: actions/checkout@v3.6.0
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v4.7.0
|
||||
id: python
|
||||
@@ -186,7 +186,7 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.5.3
|
||||
uses: actions/checkout@v3.6.0
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v4.7.0
|
||||
id: python
|
||||
@@ -227,7 +227,7 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.5.3
|
||||
uses: actions/checkout@v3.6.0
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v4.7.0
|
||||
id: python
|
||||
@@ -271,7 +271,7 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.5.3
|
||||
uses: actions/checkout@v3.6.0
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v4.7.0
|
||||
id: python
|
||||
@@ -303,7 +303,7 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.5.3
|
||||
uses: actions/checkout@v3.6.0
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v4.7.0
|
||||
id: python
|
||||
@@ -344,7 +344,7 @@ jobs:
|
||||
name: Run tests Python ${{ needs.prepare.outputs.python-version }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.5.3
|
||||
uses: actions/checkout@v3.6.0
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v4.7.0
|
||||
id: python
|
||||
@@ -402,7 +402,7 @@ jobs:
|
||||
needs: ["pytest", "prepare"]
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.5.3
|
||||
uses: actions/checkout@v3.6.0
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v4.7.0
|
||||
id: python
|
||||
|
2
.github/workflows/release-drafter.yml
vendored
2
.github/workflows/release-drafter.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
name: Release Drafter
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v3.5.3
|
||||
uses: actions/checkout@v3.6.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
2
.github/workflows/sentry.yaml
vendored
2
.github/workflows/sentry.yaml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.5.3
|
||||
uses: actions/checkout@v3.6.0
|
||||
- name: Sentry Release
|
||||
uses: getsentry/action-release@v1.4.1
|
||||
env:
|
||||
|
@@ -1,14 +1,14 @@
|
||||
aiodns==3.0.0
|
||||
aiohttp==3.8.4
|
||||
async_timeout==4.0.2
|
||||
aiohttp==3.8.5
|
||||
async_timeout==4.0.3
|
||||
atomicwrites-homeassistant==1.4.1
|
||||
attrs==23.1.0
|
||||
awesomeversion==23.5.0
|
||||
awesomeversion==23.8.0
|
||||
brotli==1.0.9
|
||||
ciso8601==2.3.0
|
||||
colorlog==6.7.0
|
||||
cpe==1.2.1
|
||||
cryptography==41.0.2
|
||||
cryptography==41.0.3
|
||||
debugpy==1.6.7
|
||||
deepmerge==1.1.0
|
||||
dirhash==0.2.1
|
||||
@@ -20,7 +20,7 @@ pulsectl==23.5.2
|
||||
pyudev==0.24.1
|
||||
ruamel.yaml==0.17.21
|
||||
securetar==2023.3.0
|
||||
sentry-sdk==1.29.0
|
||||
sentry-sdk==1.29.2
|
||||
voluptuous==0.13.1
|
||||
dbus-fast==1.86.0
|
||||
dbus-fast==1.93.0
|
||||
typing_extensions==4.7.1
|
||||
|
@@ -1,5 +1,5 @@
|
||||
black==23.7.0
|
||||
coverage==7.2.7
|
||||
coverage==7.3.0
|
||||
flake8-docstrings==1.7.0
|
||||
flake8==6.1.0
|
||||
pre-commit==3.3.3
|
||||
@@ -11,6 +11,6 @@ pytest-cov==4.1.0
|
||||
pytest-timeout==2.1.0
|
||||
pytest==7.4.0
|
||||
pyupgrade==3.10.1
|
||||
time-machine==2.11.0
|
||||
time-machine==2.12.0
|
||||
typing_extensions==4.7.1
|
||||
urllib3==2.0.4
|
||||
|
@@ -152,11 +152,15 @@ class AddonManager(CoreSysAttributes):
|
||||
capture_exception(err)
|
||||
|
||||
@Job(
|
||||
name="addon_manager_install",
|
||||
conditions=ADDON_UPDATE_CONDITIONS,
|
||||
on_condition=AddonsJobError,
|
||||
)
|
||||
async def install(self, slug: str) -> None:
|
||||
"""Install an add-on."""
|
||||
if job := self.sys_jobs.get_job():
|
||||
job.reference = slug
|
||||
|
||||
if slug in self.local:
|
||||
raise AddonsError(f"Add-on {slug} is already installed", _LOGGER.warning)
|
||||
store = self.store.get(slug)
|
||||
@@ -247,6 +251,7 @@ class AddonManager(CoreSysAttributes):
|
||||
_LOGGER.info("Add-on '%s' successfully removed", slug)
|
||||
|
||||
@Job(
|
||||
name="addon_manager_update",
|
||||
conditions=ADDON_UPDATE_CONDITIONS,
|
||||
on_condition=AddonsJobError,
|
||||
)
|
||||
@@ -258,6 +263,9 @@ class AddonManager(CoreSysAttributes):
|
||||
Returns a coroutine that completes when addon has state 'started' (see addon.start)
|
||||
if addon is started after update. Else nothing is returned.
|
||||
"""
|
||||
if job := self.sys_jobs.get_job():
|
||||
job.reference = slug
|
||||
|
||||
if slug not in self.local:
|
||||
raise AddonsError(f"Add-on {slug} is not installed", _LOGGER.error)
|
||||
addon = self.local[slug]
|
||||
@@ -307,6 +315,7 @@ class AddonManager(CoreSysAttributes):
|
||||
)
|
||||
|
||||
@Job(
|
||||
name="addon_manager_rebuild",
|
||||
conditions=[
|
||||
JobCondition.FREE_SPACE,
|
||||
JobCondition.INTERNET_HOST,
|
||||
@@ -320,6 +329,9 @@ class AddonManager(CoreSysAttributes):
|
||||
Returns a coroutine that completes when addon has state 'started' (see addon.start)
|
||||
if addon is started after rebuild. Else nothing is returned.
|
||||
"""
|
||||
if job := self.sys_jobs.get_job():
|
||||
job.reference = slug
|
||||
|
||||
if slug not in self.local:
|
||||
raise AddonsError(f"Add-on {slug} is not installed", _LOGGER.error)
|
||||
addon = self.local[slug]
|
||||
@@ -359,6 +371,7 @@ class AddonManager(CoreSysAttributes):
|
||||
)
|
||||
|
||||
@Job(
|
||||
name="addon_manager_restore",
|
||||
conditions=[
|
||||
JobCondition.FREE_SPACE,
|
||||
JobCondition.INTERNET_HOST,
|
||||
@@ -374,6 +387,9 @@ class AddonManager(CoreSysAttributes):
|
||||
Returns a coroutine that completes when addon has state 'started' (see addon.start)
|
||||
if addon is started after restore. Else nothing is returned.
|
||||
"""
|
||||
if job := self.sys_jobs.get_job():
|
||||
job.reference = slug
|
||||
|
||||
if slug not in self.local:
|
||||
_LOGGER.debug("Add-on %s is not local available for restore", slug)
|
||||
addon = Addon(self.coresys, slug)
|
||||
@@ -396,7 +412,10 @@ class AddonManager(CoreSysAttributes):
|
||||
|
||||
return wait_for_start
|
||||
|
||||
@Job(conditions=[JobCondition.FREE_SPACE, JobCondition.INTERNET_HOST])
|
||||
@Job(
|
||||
name="addon_manager_repair",
|
||||
conditions=[JobCondition.FREE_SPACE, JobCondition.INTERNET_HOST],
|
||||
)
|
||||
async def repair(self) -> None:
|
||||
"""Repair local add-ons."""
|
||||
needs_repair: list[Addon] = []
|
||||
|
@@ -129,54 +129,7 @@ class Addon(AddonModel):
|
||||
)
|
||||
self._listeners: list[EventListener] = []
|
||||
self._startup_event = asyncio.Event()
|
||||
|
||||
@Job(
|
||||
name=f"addon_{slug}_restart_after_problem",
|
||||
limit=JobExecutionLimit.THROTTLE_RATE_LIMIT,
|
||||
throttle_period=WATCHDOG_THROTTLE_PERIOD,
|
||||
throttle_max_calls=WATCHDOG_THROTTLE_MAX_CALLS,
|
||||
on_condition=AddonsJobError,
|
||||
)
|
||||
async def restart_after_problem(addon: Addon, state: ContainerState):
|
||||
"""Restart unhealthy or failed addon."""
|
||||
attempts = 0
|
||||
while await addon.instance.current_state() == state:
|
||||
if not addon.in_progress:
|
||||
_LOGGER.warning(
|
||||
"Watchdog found addon %s is %s, restarting...",
|
||||
addon.name,
|
||||
state.value,
|
||||
)
|
||||
try:
|
||||
if state == ContainerState.FAILED:
|
||||
# Ensure failed container is removed before attempting reanimation
|
||||
if attempts == 0:
|
||||
with suppress(DockerError):
|
||||
await addon.instance.stop(remove_container=True)
|
||||
|
||||
await (await addon.start())
|
||||
else:
|
||||
await (await addon.restart())
|
||||
except AddonsError as err:
|
||||
attempts = attempts + 1
|
||||
_LOGGER.error(
|
||||
"Watchdog restart of addon %s failed!", addon.name
|
||||
)
|
||||
capture_exception(err)
|
||||
else:
|
||||
break
|
||||
|
||||
if attempts >= WATCHDOG_MAX_ATTEMPTS:
|
||||
_LOGGER.critical(
|
||||
"Watchdog cannot restart addon %s, failed all %s attempts",
|
||||
addon.name,
|
||||
attempts,
|
||||
)
|
||||
break
|
||||
|
||||
await asyncio.sleep(WATCHDOG_RETRY_SECONDS)
|
||||
|
||||
self._restart_after_problem = restart_after_problem
|
||||
self._startup_task: asyncio.Task | None = None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return internal representation."""
|
||||
@@ -608,6 +561,12 @@ class Addon(AddonModel):
|
||||
|
||||
async def unload(self) -> None:
|
||||
"""Unload add-on and remove data."""
|
||||
if self._startup_task:
|
||||
# If we were waiting on startup, cancel that and let the task finish before proceeding
|
||||
self._startup_task.cancel(f"Removing add-on {self.name} from system")
|
||||
with suppress(asyncio.CancelledError):
|
||||
await self._startup_task
|
||||
|
||||
for listener in self._listeners:
|
||||
self.sys_bus.remove_listener(listener)
|
||||
|
||||
@@ -699,13 +658,18 @@ class Addon(AddonModel):
|
||||
async def _wait_for_startup(self) -> None:
|
||||
"""Wait for startup event to be set with timeout."""
|
||||
try:
|
||||
await asyncio.wait_for(self._startup_event.wait(), STARTUP_TIMEOUT)
|
||||
self._startup_task = self.sys_create_task(self._startup_event.wait())
|
||||
await asyncio.wait_for(self._startup_task, STARTUP_TIMEOUT)
|
||||
except asyncio.TimeoutError:
|
||||
_LOGGER.warning(
|
||||
"Timeout while waiting for addon %s to start, took more then %s seconds",
|
||||
self.name,
|
||||
STARTUP_TIMEOUT,
|
||||
)
|
||||
except asyncio.CancelledError as err:
|
||||
_LOGGER.info("Wait for addon startup task cancelled due to: %s", err)
|
||||
finally:
|
||||
self._startup_task = None
|
||||
|
||||
async def start(self) -> Awaitable[None]:
|
||||
"""Set options and start add-on.
|
||||
@@ -779,10 +743,7 @@ class Addon(AddonModel):
|
||||
raise AddonsError() from err
|
||||
|
||||
async def write_stdin(self, data) -> None:
|
||||
"""Write data to add-on stdin.
|
||||
|
||||
Return a coroutine.
|
||||
"""
|
||||
"""Write data to add-on stdin."""
|
||||
if not self.with_stdin:
|
||||
raise AddonsNotSupportedError(
|
||||
f"Add-on {self.slug} does not support writing to stdin!", _LOGGER.error
|
||||
@@ -877,7 +838,10 @@ class Addon(AddonModel):
|
||||
await self._backup_command(self.backup_pre)
|
||||
elif is_running and self.backup_mode == AddonBackupMode.COLD:
|
||||
_LOGGER.info("Shutdown add-on %s for cold backup", self.slug)
|
||||
await self.instance.stop()
|
||||
try:
|
||||
await self.instance.stop()
|
||||
except DockerError as err:
|
||||
raise AddonsError() from err
|
||||
|
||||
try:
|
||||
_LOGGER.info("Building backup for add-on %s", self.slug)
|
||||
@@ -950,6 +914,11 @@ class Addon(AddonModel):
|
||||
self.slug, data[ATTR_USER], data[ATTR_SYSTEM], restore_image
|
||||
)
|
||||
|
||||
# Stop it first if its running
|
||||
if await self.instance.is_running():
|
||||
with suppress(DockerError):
|
||||
await self.instance.stop()
|
||||
|
||||
# Check version / restore image
|
||||
version = data[ATTR_VERSION]
|
||||
if not await self.instance.exists():
|
||||
@@ -967,9 +936,6 @@ class Addon(AddonModel):
|
||||
_LOGGER.info("Restore/Update of image for addon %s", self.slug)
|
||||
with suppress(DockerError):
|
||||
await self.instance.update(version, restore_image)
|
||||
else:
|
||||
with suppress(DockerError):
|
||||
await self.instance.stop()
|
||||
|
||||
# Restore data
|
||||
def _restore_data():
|
||||
@@ -1019,6 +985,50 @@ class Addon(AddonModel):
|
||||
"""
|
||||
return self.instance.check_trust()
|
||||
|
||||
@Job(
|
||||
name="addon_restart_after_problem",
|
||||
limit=JobExecutionLimit.GROUP_THROTTLE_RATE_LIMIT,
|
||||
throttle_period=WATCHDOG_THROTTLE_PERIOD,
|
||||
throttle_max_calls=WATCHDOG_THROTTLE_MAX_CALLS,
|
||||
on_condition=AddonsJobError,
|
||||
)
|
||||
async def _restart_after_problem(self, state: ContainerState):
|
||||
"""Restart unhealthy or failed addon."""
|
||||
attempts = 0
|
||||
while await self.instance.current_state() == state:
|
||||
if not self.in_progress:
|
||||
_LOGGER.warning(
|
||||
"Watchdog found addon %s is %s, restarting...",
|
||||
self.name,
|
||||
state.value,
|
||||
)
|
||||
try:
|
||||
if state == ContainerState.FAILED:
|
||||
# Ensure failed container is removed before attempting reanimation
|
||||
if attempts == 0:
|
||||
with suppress(DockerError):
|
||||
await self.instance.stop(remove_container=True)
|
||||
|
||||
await (await self.start())
|
||||
else:
|
||||
await (await self.restart())
|
||||
except AddonsError as err:
|
||||
attempts = attempts + 1
|
||||
_LOGGER.error("Watchdog restart of addon %s failed!", self.name)
|
||||
capture_exception(err)
|
||||
else:
|
||||
break
|
||||
|
||||
if attempts >= WATCHDOG_MAX_ATTEMPTS:
|
||||
_LOGGER.critical(
|
||||
"Watchdog cannot restart addon %s, failed all %s attempts",
|
||||
self.name,
|
||||
attempts,
|
||||
)
|
||||
break
|
||||
|
||||
await asyncio.sleep(WATCHDOG_RETRY_SECONDS)
|
||||
|
||||
async def container_state_changed(self, event: DockerContainerStateEvent) -> None:
|
||||
"""Set addon state from container state."""
|
||||
if event.name != self.instance.name:
|
||||
@@ -1053,4 +1063,4 @@ class Addon(AddonModel):
|
||||
ContainerState.STOPPED,
|
||||
ContainerState.UNHEALTHY,
|
||||
]:
|
||||
await self._restart_after_problem(self, event.state)
|
||||
await self._restart_after_problem(event.state)
|
||||
|
@@ -1,5 +1,6 @@
|
||||
"""Init file for Supervisor add-ons."""
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import defaultdict
|
||||
from collections.abc import Awaitable, Callable
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
@@ -79,9 +80,11 @@ from ..const import (
|
||||
AddonStage,
|
||||
AddonStartup,
|
||||
)
|
||||
from ..coresys import CoreSys, CoreSysAttributes
|
||||
from ..coresys import CoreSys
|
||||
from ..docker.const import Capabilities
|
||||
from ..exceptions import AddonsNotSupportedError
|
||||
from ..jobs.const import JOB_GROUP_ADDON
|
||||
from ..jobs.job_group import JobGroup
|
||||
from .const import ATTR_BACKUP, ATTR_CODENOTARY, AddonBackupMode
|
||||
from .options import AddonOptions, UiOptions
|
||||
from .validate import RE_SERVICE, RE_VOLUME
|
||||
@@ -91,12 +94,14 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
Data = dict[str, Any]
|
||||
|
||||
|
||||
class AddonModel(CoreSysAttributes, ABC):
|
||||
class AddonModel(JobGroup, ABC):
|
||||
"""Add-on Data layout."""
|
||||
|
||||
def __init__(self, coresys: CoreSys, slug: str):
|
||||
"""Initialize data holder."""
|
||||
self.coresys: CoreSys = coresys
|
||||
super().__init__(
|
||||
coresys, JOB_GROUP_ADDON.format_map(defaultdict(str, slug=slug)), slug
|
||||
)
|
||||
self.slug: str = slug
|
||||
|
||||
@property
|
||||
|
@@ -143,6 +143,8 @@ RE_MACHINE = re.compile(
|
||||
r"|raspberrypi3"
|
||||
r"|raspberrypi4-64"
|
||||
r"|raspberrypi4"
|
||||
r"|yellow"
|
||||
r"|green"
|
||||
r"|tinker"
|
||||
r")$"
|
||||
)
|
||||
|
@@ -1,11 +1,11 @@
|
||||
"""Init file for Supervisor Audio RESTful API."""
|
||||
import asyncio
|
||||
from collections.abc import Awaitable
|
||||
from dataclasses import asdict
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
import attr
|
||||
import voluptuous as vol
|
||||
|
||||
from ..const import (
|
||||
@@ -76,15 +76,11 @@ class APIAudio(CoreSysAttributes):
|
||||
ATTR_UPDATE_AVAILABLE: self.sys_plugins.audio.need_update,
|
||||
ATTR_HOST: str(self.sys_docker.network.audio),
|
||||
ATTR_AUDIO: {
|
||||
ATTR_CARD: [attr.asdict(card) for card in self.sys_host.sound.cards],
|
||||
ATTR_INPUT: [
|
||||
attr.asdict(stream) for stream in self.sys_host.sound.inputs
|
||||
],
|
||||
ATTR_OUTPUT: [
|
||||
attr.asdict(stream) for stream in self.sys_host.sound.outputs
|
||||
],
|
||||
ATTR_CARD: [asdict(card) for card in self.sys_host.sound.cards],
|
||||
ATTR_INPUT: [asdict(stream) for stream in self.sys_host.sound.inputs],
|
||||
ATTR_OUTPUT: [asdict(stream) for stream in self.sys_host.sound.outputs],
|
||||
ATTR_APPLICATION: [
|
||||
attr.asdict(stream) for stream in self.sys_host.sound.applications
|
||||
asdict(stream) for stream in self.sys_host.sound.applications
|
||||
],
|
||||
},
|
||||
}
|
||||
|
@@ -21,11 +21,18 @@ from ..const import (
|
||||
ATTR_ICON,
|
||||
ATTR_PANELS,
|
||||
ATTR_SESSION,
|
||||
ATTR_SESSION_DATA_USER_ID,
|
||||
ATTR_TITLE,
|
||||
HEADER_REMOTE_USER_DISPLAY_NAME,
|
||||
HEADER_REMOTE_USER_ID,
|
||||
HEADER_REMOTE_USER_NAME,
|
||||
HEADER_TOKEN,
|
||||
HEADER_TOKEN_OLD,
|
||||
IngressSessionData,
|
||||
IngressSessionDataUser,
|
||||
)
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..exceptions import HomeAssistantAPIError
|
||||
from .const import COOKIE_INGRESS
|
||||
from .utils import api_process, api_validate, require_home_assistant
|
||||
|
||||
@@ -33,10 +40,23 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
VALIDATE_SESSION_DATA = vol.Schema({ATTR_SESSION: str})
|
||||
|
||||
"""Expected optional payload of create session request"""
|
||||
SCHEMA_INGRESS_CREATE_SESSION_DATA = vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_SESSION_DATA_USER_ID): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class APIIngress(CoreSysAttributes):
|
||||
"""Ingress view to handle add-on webui routing."""
|
||||
|
||||
_list_of_users: list[IngressSessionDataUser]
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize APIIngress."""
|
||||
self._list_of_users = []
|
||||
|
||||
def _extract_addon(self, request: web.Request) -> Addon:
|
||||
"""Return addon, throw an exception it it doesn't exist."""
|
||||
token = request.match_info.get("token")
|
||||
@@ -71,7 +91,19 @@ class APIIngress(CoreSysAttributes):
|
||||
@require_home_assistant
|
||||
async def create_session(self, request: web.Request) -> dict[str, Any]:
|
||||
"""Create a new session."""
|
||||
session = self.sys_ingress.create_session()
|
||||
schema_ingress_config_session_data = await api_validate(
|
||||
SCHEMA_INGRESS_CREATE_SESSION_DATA, request
|
||||
)
|
||||
data: IngressSessionData | None = None
|
||||
|
||||
if ATTR_SESSION_DATA_USER_ID in schema_ingress_config_session_data:
|
||||
user = await self._find_user_by_id(
|
||||
schema_ingress_config_session_data[ATTR_SESSION_DATA_USER_ID]
|
||||
)
|
||||
if user:
|
||||
data = IngressSessionData(user)
|
||||
|
||||
session = self.sys_ingress.create_session(data)
|
||||
return {ATTR_SESSION: session}
|
||||
|
||||
@api_process
|
||||
@@ -99,13 +131,14 @@ class APIIngress(CoreSysAttributes):
|
||||
# Process requests
|
||||
addon = self._extract_addon(request)
|
||||
path = request.match_info.get("path")
|
||||
session_data = self.sys_ingress.get_session_data(session)
|
||||
try:
|
||||
# Websocket
|
||||
if _is_websocket(request):
|
||||
return await self._handle_websocket(request, addon, path)
|
||||
return await self._handle_websocket(request, addon, path, session_data)
|
||||
|
||||
# Request
|
||||
return await self._handle_request(request, addon, path)
|
||||
return await self._handle_request(request, addon, path, session_data)
|
||||
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.error("Ingress error: %s", err)
|
||||
@@ -113,7 +146,11 @@ class APIIngress(CoreSysAttributes):
|
||||
raise HTTPBadGateway()
|
||||
|
||||
async def _handle_websocket(
|
||||
self, request: web.Request, addon: Addon, path: str
|
||||
self,
|
||||
request: web.Request,
|
||||
addon: Addon,
|
||||
path: str,
|
||||
session_data: IngressSessionData | None,
|
||||
) -> web.WebSocketResponse:
|
||||
"""Ingress route for websocket."""
|
||||
if hdrs.SEC_WEBSOCKET_PROTOCOL in request.headers:
|
||||
@@ -131,7 +168,7 @@ class APIIngress(CoreSysAttributes):
|
||||
|
||||
# Preparing
|
||||
url = self._create_url(addon, path)
|
||||
source_header = _init_header(request, addon)
|
||||
source_header = _init_header(request, addon, session_data)
|
||||
|
||||
# Support GET query
|
||||
if request.query_string:
|
||||
@@ -157,11 +194,15 @@ class APIIngress(CoreSysAttributes):
|
||||
return ws_server
|
||||
|
||||
async def _handle_request(
|
||||
self, request: web.Request, addon: Addon, path: str
|
||||
self,
|
||||
request: web.Request,
|
||||
addon: Addon,
|
||||
path: str,
|
||||
session_data: IngressSessionData | None,
|
||||
) -> web.Response | web.StreamResponse:
|
||||
"""Ingress route for request."""
|
||||
url = self._create_url(addon, path)
|
||||
source_header = _init_header(request, addon)
|
||||
source_header = _init_header(request, addon, session_data)
|
||||
|
||||
# Passing the raw stream breaks requests for some webservers
|
||||
# since we just need it for POST requests really, for all other methods
|
||||
@@ -217,11 +258,33 @@ class APIIngress(CoreSysAttributes):
|
||||
|
||||
return response
|
||||
|
||||
async def _find_user_by_id(self, user_id: str) -> IngressSessionDataUser | None:
|
||||
"""Find user object by the user's ID."""
|
||||
try:
|
||||
list_of_users = await self.sys_homeassistant.get_users()
|
||||
except (HomeAssistantAPIError, TypeError) as err:
|
||||
_LOGGER.error(
|
||||
"%s error occurred while requesting list of users: %s", type(err), err
|
||||
)
|
||||
return None
|
||||
|
||||
def _init_header(request: web.Request, addon: str) -> CIMultiDict | dict[str, str]:
|
||||
if list_of_users is not None:
|
||||
self._list_of_users = list_of_users
|
||||
|
||||
return next((user for user in self._list_of_users if user.id == user_id), None)
|
||||
|
||||
|
||||
def _init_header(
|
||||
request: web.Request, addon: Addon, session_data: IngressSessionData | None
|
||||
) -> CIMultiDict | dict[str, str]:
|
||||
"""Create initial header."""
|
||||
headers = {}
|
||||
|
||||
if session_data is not None:
|
||||
headers[HEADER_REMOTE_USER_ID] = session_data.user.id
|
||||
headers[HEADER_REMOTE_USER_NAME] = session_data.user.username
|
||||
headers[HEADER_REMOTE_USER_DISPLAY_NAME] = session_data.user.display_name
|
||||
|
||||
# filter flags
|
||||
for name, value in request.headers.items():
|
||||
if name in (
|
||||
@@ -234,6 +297,9 @@ def _init_header(request: web.Request, addon: str) -> CIMultiDict | dict[str, st
|
||||
hdrs.SEC_WEBSOCKET_KEY,
|
||||
istr(HEADER_TOKEN),
|
||||
istr(HEADER_TOKEN_OLD),
|
||||
istr(HEADER_REMOTE_USER_ID),
|
||||
istr(HEADER_REMOTE_USER_NAME),
|
||||
istr(HEADER_REMOTE_USER_DISPLAY_NAME),
|
||||
):
|
||||
continue
|
||||
headers[name] = value
|
||||
|
@@ -277,6 +277,7 @@ class APINetwork(CoreSysAttributes):
|
||||
)
|
||||
|
||||
vlan_interface = Interface(
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
True,
|
||||
|
@@ -110,6 +110,10 @@ class BackupManager(FileConfiguration, CoreSysAttributes):
|
||||
backup.store_repositories()
|
||||
backup.store_dockerconfig()
|
||||
|
||||
# Add backup ID to job
|
||||
if job := self.sys_jobs.get_job():
|
||||
job.reference = backup.slug
|
||||
|
||||
return backup
|
||||
|
||||
def load(self):
|
||||
@@ -224,7 +228,10 @@ class BackupManager(FileConfiguration, CoreSysAttributes):
|
||||
finally:
|
||||
self.sys_core.state = CoreState.RUNNING
|
||||
|
||||
@Job(conditions=[JobCondition.FREE_SPACE, JobCondition.RUNNING])
|
||||
@Job(
|
||||
name="backup_manager_full_backup",
|
||||
conditions=[JobCondition.FREE_SPACE, JobCondition.RUNNING],
|
||||
)
|
||||
async def do_backup_full(
|
||||
self,
|
||||
name="",
|
||||
@@ -250,7 +257,10 @@ class BackupManager(FileConfiguration, CoreSysAttributes):
|
||||
_LOGGER.info("Creating full backup with slug %s completed", backup.slug)
|
||||
return backup
|
||||
|
||||
@Job(conditions=[JobCondition.FREE_SPACE, JobCondition.RUNNING])
|
||||
@Job(
|
||||
name="backup_manager_partial_backup",
|
||||
conditions=[JobCondition.FREE_SPACE, JobCondition.RUNNING],
|
||||
)
|
||||
async def do_backup_partial(
|
||||
self,
|
||||
name: str = "",
|
||||
@@ -371,16 +381,21 @@ class BackupManager(FileConfiguration, CoreSysAttributes):
|
||||
await self.sys_homeassistant.core.restart()
|
||||
|
||||
@Job(
|
||||
name="backup_manager_full_restore",
|
||||
conditions=[
|
||||
JobCondition.FREE_SPACE,
|
||||
JobCondition.HEALTHY,
|
||||
JobCondition.INTERNET_HOST,
|
||||
JobCondition.INTERNET_SYSTEM,
|
||||
JobCondition.RUNNING,
|
||||
]
|
||||
],
|
||||
)
|
||||
async def do_restore_full(self, backup: Backup, password=None):
|
||||
"""Restore a backup."""
|
||||
# Add backup ID to job
|
||||
if job := self.sys_jobs.get_job():
|
||||
job.reference = backup.slug
|
||||
|
||||
if self.lock.locked():
|
||||
_LOGGER.error("A backup/restore process is already running")
|
||||
return False
|
||||
@@ -418,13 +433,14 @@ class BackupManager(FileConfiguration, CoreSysAttributes):
|
||||
_LOGGER.info("Full-Restore %s done", backup.slug)
|
||||
|
||||
@Job(
|
||||
name="backup_manager_partial_restore",
|
||||
conditions=[
|
||||
JobCondition.FREE_SPACE,
|
||||
JobCondition.HEALTHY,
|
||||
JobCondition.INTERNET_HOST,
|
||||
JobCondition.INTERNET_SYSTEM,
|
||||
JobCondition.RUNNING,
|
||||
]
|
||||
],
|
||||
)
|
||||
async def do_restore_partial(
|
||||
self,
|
||||
@@ -435,6 +451,10 @@ class BackupManager(FileConfiguration, CoreSysAttributes):
|
||||
password: str | None = None,
|
||||
):
|
||||
"""Restore a backup."""
|
||||
# Add backup ID to job
|
||||
if job := self.sys_jobs.get_job():
|
||||
job.reference = backup.slug
|
||||
|
||||
if self.lock.locked():
|
||||
_LOGGER.error("A backup/restore process is already running")
|
||||
return False
|
||||
|
@@ -1,4 +1,5 @@
|
||||
"""Constants file for Supervisor."""
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from ipaddress import ip_network
|
||||
from pathlib import Path
|
||||
@@ -69,6 +70,9 @@ JSON_RESULT = "result"
|
||||
RESULT_ERROR = "error"
|
||||
RESULT_OK = "ok"
|
||||
|
||||
HEADER_REMOTE_USER_ID = "X-Remote-User-Id"
|
||||
HEADER_REMOTE_USER_NAME = "X-Remote-User-Name"
|
||||
HEADER_REMOTE_USER_DISPLAY_NAME = "X-Remote-User-Display-Name"
|
||||
HEADER_TOKEN_OLD = "X-Hassio-Key"
|
||||
HEADER_TOKEN = "X-Supervisor-Token"
|
||||
|
||||
@@ -271,6 +275,9 @@ ATTR_SERVERS = "servers"
|
||||
ATTR_SERVICE = "service"
|
||||
ATTR_SERVICES = "services"
|
||||
ATTR_SESSION = "session"
|
||||
ATTR_SESSION_DATA = "session_data"
|
||||
ATTR_SESSION_DATA_USER = "user"
|
||||
ATTR_SESSION_DATA_USER_ID = "user_id"
|
||||
ATTR_SIGNAL = "signal"
|
||||
ATTR_SIZE = "size"
|
||||
ATTR_SLUG = "slug"
|
||||
@@ -464,6 +471,22 @@ class CpuArch(str, Enum):
|
||||
AMD64 = "amd64"
|
||||
|
||||
|
||||
@dataclass
|
||||
class IngressSessionDataUser:
|
||||
"""Format of an IngressSessionDataUser object."""
|
||||
|
||||
id: str
|
||||
display_name: str
|
||||
username: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class IngressSessionData:
|
||||
"""Format of an IngressSessionData object."""
|
||||
|
||||
user: IngressSessionDataUser
|
||||
|
||||
|
||||
STARTING_STATES = [
|
||||
CoreState.INITIALIZE,
|
||||
CoreState.STARTUP,
|
||||
|
@@ -70,13 +70,16 @@ class Core(CoreSysAttributes):
|
||||
)
|
||||
finally:
|
||||
self._state = new_state
|
||||
self.sys_bus.fire_event(BusEvent.SUPERVISOR_STATE_CHANGE, new_state)
|
||||
|
||||
# These will be received by HA after startup has completed which won't make sense
|
||||
if new_state not in STARTING_STATES:
|
||||
self.sys_homeassistant.websocket.supervisor_update_event(
|
||||
"info", {"state": new_state}
|
||||
)
|
||||
# Don't attempt to notify anyone on CLOSE as we're about to stop the event loop
|
||||
if new_state != CoreState.CLOSE:
|
||||
self.sys_bus.fire_event(BusEvent.SUPERVISOR_STATE_CHANGE, new_state)
|
||||
|
||||
# These will be received by HA after startup has completed which won't make sense
|
||||
if new_state not in STARTING_STATES:
|
||||
self.sys_homeassistant.websocket.supervisor_update_event(
|
||||
"info", {"state": new_state}
|
||||
)
|
||||
|
||||
async def connect(self):
|
||||
"""Connect Supervisor container."""
|
||||
|
@@ -6,6 +6,7 @@
|
||||
"raspberrypi4": ["armv7", "armhf"],
|
||||
"raspberrypi4-64": ["aarch64", "armv7", "armhf"],
|
||||
"yellow": ["aarch64", "armv7", "armhf"],
|
||||
"green": ["aarch64", "armv7", "armhf"],
|
||||
"tinker": ["armv7", "armhf"],
|
||||
"odroid-c2": ["aarch64", "armv7", "armhf"],
|
||||
"odroid-c4": ["aarch64", "armv7", "armhf"],
|
||||
|
@@ -144,6 +144,7 @@ DBUS_ATTR_OPERATION = "Operation"
|
||||
DBUS_ATTR_OPTIONS = "Options"
|
||||
DBUS_ATTR_PARSER_VERSION = "ParserVersion"
|
||||
DBUS_ATTR_PARTITIONS = "Partitions"
|
||||
DBUS_ATTR_PATH = "Path"
|
||||
DBUS_ATTR_POWER_LED = "PowerLED"
|
||||
DBUS_ATTR_PRIMARY_CONNECTION = "PrimaryConnection"
|
||||
DBUS_ATTR_READ_ONLY = "ReadOnly"
|
||||
|
@@ -66,7 +66,7 @@ class IpProperties:
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DeviceProperties:
|
||||
"""Device properties object for Network Manager."""
|
||||
class MatchProperties:
|
||||
"""Match properties object for Network Manager."""
|
||||
|
||||
match_device: str | None
|
||||
path: list[str] | None = None
|
||||
|
@@ -11,6 +11,7 @@ from ..const import (
|
||||
DBUS_ATTR_DRIVER,
|
||||
DBUS_ATTR_HWADDRESS,
|
||||
DBUS_ATTR_MANAGED,
|
||||
DBUS_ATTR_PATH,
|
||||
DBUS_IFACE_DEVICE,
|
||||
DBUS_NAME_NM,
|
||||
DBUS_OBJECT_BASE,
|
||||
@@ -74,6 +75,12 @@ class NetworkInterface(DBusInterfaceProxy):
|
||||
"""Return hardware address (i.e. mac address) of device."""
|
||||
return self.properties[DBUS_ATTR_HWADDRESS]
|
||||
|
||||
@property
|
||||
@dbus_property
|
||||
def path(self) -> str:
|
||||
"""Return The path of the device as exposed by the udev property ID_PATH."""
|
||||
return self.properties[DBUS_ATTR_PATH]
|
||||
|
||||
@property
|
||||
def connection(self) -> NetworkConnection | None:
|
||||
"""Return the connection used for this interface."""
|
||||
|
@@ -11,9 +11,9 @@ from ...interface import DBusInterface
|
||||
from ...utils import dbus_connected
|
||||
from ..configuration import (
|
||||
ConnectionProperties,
|
||||
DeviceProperties,
|
||||
EthernetProperties,
|
||||
IpProperties,
|
||||
MatchProperties,
|
||||
VlanProperties,
|
||||
WirelessProperties,
|
||||
WirelessSecurityProperties,
|
||||
@@ -26,7 +26,8 @@ CONF_ATTR_802_WIRELESS_SECURITY = "802-11-wireless-security"
|
||||
CONF_ATTR_VLAN = "vlan"
|
||||
CONF_ATTR_IPV4 = "ipv4"
|
||||
CONF_ATTR_IPV6 = "ipv6"
|
||||
CONF_ATTR_DEVICE = "device"
|
||||
CONF_ATTR_MATCH = "match"
|
||||
CONF_ATTR_PATH = "path"
|
||||
|
||||
ATTR_ID = "id"
|
||||
ATTR_UUID = "uuid"
|
||||
@@ -37,7 +38,7 @@ ATTR_POWERSAVE = "powersave"
|
||||
ATTR_AUTH_ALG = "auth-alg"
|
||||
ATTR_KEY_MGMT = "key-mgmt"
|
||||
ATTR_INTERFACE_NAME = "interface-name"
|
||||
ATTR_MATCH_DEVICE = "match-device"
|
||||
ATTR_PATH = "path"
|
||||
|
||||
IPV4_6_IGNORE_FIELDS = [
|
||||
"addresses",
|
||||
@@ -88,7 +89,7 @@ class NetworkSetting(DBusInterface):
|
||||
self._vlan: VlanProperties | None = None
|
||||
self._ipv4: IpProperties | None = None
|
||||
self._ipv6: IpProperties | None = None
|
||||
self._device: DeviceProperties | None = None
|
||||
self._match: MatchProperties | None = None
|
||||
|
||||
@property
|
||||
def connection(self) -> ConnectionProperties | None:
|
||||
@@ -126,9 +127,9 @@ class NetworkSetting(DBusInterface):
|
||||
return self._ipv6
|
||||
|
||||
@property
|
||||
def device(self) -> DeviceProperties | None:
|
||||
"""Return device properties if any."""
|
||||
return self._device
|
||||
def match(self) -> MatchProperties | None:
|
||||
"""Return match properties if any."""
|
||||
return self._match
|
||||
|
||||
@dbus_connected
|
||||
async def get_settings(self) -> dict[str, Any]:
|
||||
@@ -166,7 +167,7 @@ class NetworkSetting(DBusInterface):
|
||||
CONF_ATTR_IPV6,
|
||||
ignore_current_value=IPV4_6_IGNORE_FIELDS,
|
||||
)
|
||||
_merge_settings_attribute(new_settings, settings, CONF_ATTR_DEVICE)
|
||||
_merge_settings_attribute(new_settings, settings, CONF_ATTR_MATCH)
|
||||
|
||||
await self.dbus.Settings.Connection.call_update(new_settings)
|
||||
|
||||
@@ -233,7 +234,5 @@ class NetworkSetting(DBusInterface):
|
||||
data[CONF_ATTR_IPV6].get(ATTR_METHOD),
|
||||
)
|
||||
|
||||
if CONF_ATTR_DEVICE in data:
|
||||
self._device = DeviceProperties(
|
||||
data[CONF_ATTR_DEVICE].get(ATTR_MATCH_DEVICE)
|
||||
)
|
||||
if CONF_ATTR_MATCH in data:
|
||||
self._match = MatchProperties(data[CONF_ATTR_MATCH].get(ATTR_PATH))
|
||||
|
@@ -9,14 +9,14 @@ from dbus_fast import Variant
|
||||
|
||||
from . import (
|
||||
ATTR_ASSIGNED_MAC,
|
||||
ATTR_MATCH_DEVICE,
|
||||
CONF_ATTR_802_ETHERNET,
|
||||
CONF_ATTR_802_WIRELESS,
|
||||
CONF_ATTR_802_WIRELESS_SECURITY,
|
||||
CONF_ATTR_CONNECTION,
|
||||
CONF_ATTR_DEVICE,
|
||||
CONF_ATTR_IPV4,
|
||||
CONF_ATTR_IPV6,
|
||||
CONF_ATTR_MATCH,
|
||||
CONF_ATTR_PATH,
|
||||
CONF_ATTR_VLAN,
|
||||
)
|
||||
from ....host.const import InterfaceMethod, InterfaceType
|
||||
@@ -59,11 +59,10 @@ def get_connection_from_interface(
|
||||
}
|
||||
|
||||
if interface.type != InterfaceType.VLAN:
|
||||
conn[CONF_ATTR_DEVICE] = {
|
||||
ATTR_MATCH_DEVICE: Variant(
|
||||
"s", f"mac:{interface.mac},interface-name:{interface.name}"
|
||||
)
|
||||
}
|
||||
if interface.path:
|
||||
conn[CONF_ATTR_MATCH] = {CONF_ATTR_PATH: Variant("as", [interface.path])}
|
||||
else:
|
||||
conn[CONF_ATTR_CONNECTION]["interface-name"] = Variant("s", interface.name)
|
||||
|
||||
ipv4 = {}
|
||||
if not interface.ipv4 or interface.ipv4.method == InterfaceMethod.AUTO:
|
||||
|
@@ -36,14 +36,15 @@ from ..exceptions import (
|
||||
CoreDNSError,
|
||||
DBusError,
|
||||
DockerError,
|
||||
DockerJobError,
|
||||
DockerNotFound,
|
||||
HardwareNotFound,
|
||||
)
|
||||
from ..hardware.const import PolicyGroup
|
||||
from ..hardware.data import Device
|
||||
from ..jobs.decorator import Job, JobCondition, JobExecutionLimit
|
||||
from ..jobs.const import JobCondition, JobExecutionLimit
|
||||
from ..jobs.decorator import Job
|
||||
from ..resolution.const import ContextType, IssueType, SuggestionType
|
||||
from ..utils import process_lock
|
||||
from ..utils.sentry import capture_exception
|
||||
from .const import (
|
||||
ENV_TIME,
|
||||
@@ -73,8 +74,8 @@ class DockerAddon(DockerInterface):
|
||||
|
||||
def __init__(self, coresys: CoreSys, addon: Addon):
|
||||
"""Initialize Docker Home Assistant wrapper."""
|
||||
super().__init__(coresys)
|
||||
self.addon: Addon = addon
|
||||
super().__init__(coresys)
|
||||
|
||||
self._hw_listener: EventListener | None = None
|
||||
|
||||
@@ -493,7 +494,12 @@ class DockerAddon(DockerInterface):
|
||||
|
||||
return mounts
|
||||
|
||||
async def _run(self) -> None:
|
||||
@Job(
|
||||
name="docker_addon_run",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=DockerJobError,
|
||||
)
|
||||
async def run(self) -> None:
|
||||
"""Run Docker image."""
|
||||
if await self.is_running():
|
||||
return
|
||||
@@ -503,7 +509,7 @@ class DockerAddon(DockerInterface):
|
||||
_LOGGER.warning("%s running with disabled protected mode!", self.addon.name)
|
||||
|
||||
# Cleanup
|
||||
await self._stop()
|
||||
await self.stop()
|
||||
|
||||
# Don't set a hostname if no separate UTS namespace is used
|
||||
hostname = None if self.uts_mode else self.addon.hostname
|
||||
@@ -563,7 +569,12 @@ class DockerAddon(DockerInterface):
|
||||
BusEvent.HARDWARE_NEW_DEVICE, self._hardware_events
|
||||
)
|
||||
|
||||
async def _update(
|
||||
@Job(
|
||||
name="docker_addon_update",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=DockerJobError,
|
||||
)
|
||||
async def update(
|
||||
self, version: AwesomeVersion, image: str | None = None, latest: bool = False
|
||||
) -> None:
|
||||
"""Update a docker image."""
|
||||
@@ -574,15 +585,20 @@ class DockerAddon(DockerInterface):
|
||||
)
|
||||
|
||||
# Update docker image
|
||||
await self._install(
|
||||
await self.install(
|
||||
version, image=image, latest=latest, need_build=self.addon.latest_need_build
|
||||
)
|
||||
|
||||
# Stop container & cleanup
|
||||
with suppress(DockerError):
|
||||
await self._stop()
|
||||
await self.stop()
|
||||
|
||||
async def _install(
|
||||
@Job(
|
||||
name="docker_addon_install",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=DockerJobError,
|
||||
)
|
||||
async def install(
|
||||
self,
|
||||
version: AwesomeVersion,
|
||||
image: str | None = None,
|
||||
@@ -595,7 +611,7 @@ class DockerAddon(DockerInterface):
|
||||
if need_build is None and self.addon.need_build or need_build:
|
||||
await self._build(version)
|
||||
else:
|
||||
await super()._install(version, image, latest, arch)
|
||||
await super().install(version, image, latest, arch)
|
||||
|
||||
async def _build(self, version: AwesomeVersion) -> None:
|
||||
"""Build a Docker container."""
|
||||
@@ -632,14 +648,22 @@ class DockerAddon(DockerInterface):
|
||||
|
||||
_LOGGER.info("Build %s:%s done", self.image, version)
|
||||
|
||||
@process_lock
|
||||
@Job(
|
||||
name="docker_addon_export_image",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=DockerJobError,
|
||||
)
|
||||
def export_image(self, tar_file: Path) -> Awaitable[None]:
|
||||
"""Export current images into a tar file."""
|
||||
return self.sys_run_in_executor(
|
||||
self.sys_docker.export_image, self.image, self.version, tar_file
|
||||
)
|
||||
|
||||
@process_lock
|
||||
@Job(
|
||||
name="docker_addon_import_image",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=DockerJobError,
|
||||
)
|
||||
async def import_image(self, tar_file: Path) -> None:
|
||||
"""Import a tar file as image."""
|
||||
docker_image = await self.sys_run_in_executor(
|
||||
@@ -650,9 +674,13 @@ class DockerAddon(DockerInterface):
|
||||
_LOGGER.info("Importing image %s and version %s", tar_file, self.version)
|
||||
|
||||
with suppress(DockerError):
|
||||
await self._cleanup()
|
||||
await self.cleanup()
|
||||
|
||||
@process_lock
|
||||
@Job(
|
||||
name="docker_addon_write_stdin",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=DockerJobError,
|
||||
)
|
||||
async def write_stdin(self, data: bytes) -> None:
|
||||
"""Write to add-on stdin."""
|
||||
if not await self.is_running():
|
||||
@@ -682,7 +710,12 @@ class DockerAddon(DockerInterface):
|
||||
_LOGGER.error("Can't write to %s stdin: %s", self.name, err)
|
||||
raise DockerError() from err
|
||||
|
||||
async def _stop(self, remove_container: bool = True) -> None:
|
||||
@Job(
|
||||
name="docker_addon_stop",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=DockerJobError,
|
||||
)
|
||||
async def stop(self, remove_container: bool = True) -> None:
|
||||
"""Stop/remove Docker container."""
|
||||
# DNS
|
||||
if self.ip_address != NO_ADDDRESS:
|
||||
@@ -697,7 +730,7 @@ class DockerAddon(DockerInterface):
|
||||
self.sys_bus.remove_listener(self._hw_listener)
|
||||
self._hw_listener = None
|
||||
|
||||
await super()._stop(remove_container)
|
||||
await super().stop(remove_container)
|
||||
|
||||
async def _validate_trust(
|
||||
self, image_id: str, image: str, version: AwesomeVersion
|
||||
@@ -709,7 +742,11 @@ class DockerAddon(DockerInterface):
|
||||
checksum = image_id.partition(":")[2]
|
||||
return await self.sys_security.verify_content(self.addon.codenotary, checksum)
|
||||
|
||||
@Job(conditions=[JobCondition.OS_AGENT], limit=JobExecutionLimit.SINGLE_WAIT)
|
||||
@Job(
|
||||
name="docker_addon_hardware_events",
|
||||
conditions=[JobCondition.OS_AGENT],
|
||||
limit=JobExecutionLimit.SINGLE_WAIT,
|
||||
)
|
||||
async def _hardware_events(self, device: Device) -> None:
|
||||
"""Process Hardware events for adjust device access."""
|
||||
if not any(
|
||||
|
@@ -6,7 +6,10 @@ from docker.types import Mount
|
||||
|
||||
from ..const import DOCKER_CPU_RUNTIME_ALLOCATION, MACHINE_ID
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..exceptions import DockerJobError
|
||||
from ..hardware.const import PolicyGroup
|
||||
from ..jobs.const import JobExecutionLimit
|
||||
from ..jobs.decorator import Job
|
||||
from .const import (
|
||||
ENV_TIME,
|
||||
MOUNT_DBUS,
|
||||
@@ -82,13 +85,18 @@ class DockerAudio(DockerInterface, CoreSysAttributes):
|
||||
return None
|
||||
return DOCKER_CPU_RUNTIME_ALLOCATION
|
||||
|
||||
async def _run(self) -> None:
|
||||
@Job(
|
||||
name="docker_audio_run",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=DockerJobError,
|
||||
)
|
||||
async def run(self) -> None:
|
||||
"""Run Docker image."""
|
||||
if await self.is_running():
|
||||
return
|
||||
|
||||
# Cleanup
|
||||
await self._stop()
|
||||
await self.stop()
|
||||
|
||||
# Create & Run container
|
||||
docker_container = await self.sys_run_in_executor(
|
||||
|
@@ -2,6 +2,9 @@
|
||||
import logging
|
||||
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..exceptions import DockerJobError
|
||||
from ..jobs.const import JobExecutionLimit
|
||||
from ..jobs.decorator import Job
|
||||
from .const import ENV_TIME, ENV_TOKEN
|
||||
from .interface import DockerInterface
|
||||
|
||||
@@ -23,13 +26,18 @@ class DockerCli(DockerInterface, CoreSysAttributes):
|
||||
"""Return name of Docker container."""
|
||||
return CLI_DOCKER_NAME
|
||||
|
||||
async def _run(self) -> None:
|
||||
@Job(
|
||||
name="docker_cli_run",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=DockerJobError,
|
||||
)
|
||||
async def run(self) -> None:
|
||||
"""Run Docker image."""
|
||||
if await self.is_running():
|
||||
return
|
||||
|
||||
# Cleanup
|
||||
await self._stop()
|
||||
await self.stop()
|
||||
|
||||
# Create & Run container
|
||||
docker_container = await self.sys_run_in_executor(
|
||||
|
@@ -4,6 +4,9 @@ import logging
|
||||
from docker.types import Mount
|
||||
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..exceptions import DockerJobError
|
||||
from ..jobs.const import JobExecutionLimit
|
||||
from ..jobs.decorator import Job
|
||||
from .const import ENV_TIME, MOUNT_DBUS, MountType
|
||||
from .interface import DockerInterface
|
||||
|
||||
@@ -25,13 +28,18 @@ class DockerDNS(DockerInterface, CoreSysAttributes):
|
||||
"""Return name of Docker container."""
|
||||
return DNS_DOCKER_NAME
|
||||
|
||||
async def _run(self) -> None:
|
||||
@Job(
|
||||
name="docker_dns_run",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=DockerJobError,
|
||||
)
|
||||
async def run(self) -> None:
|
||||
"""Run Docker image."""
|
||||
if await self.is_running():
|
||||
return
|
||||
|
||||
# Cleanup
|
||||
await self._stop()
|
||||
await self.stop()
|
||||
|
||||
# Create & Run container
|
||||
docker_container = await self.sys_run_in_executor(
|
||||
|
@@ -7,9 +7,11 @@ from awesomeversion import AwesomeVersion, AwesomeVersionCompareException
|
||||
from docker.types import Mount
|
||||
|
||||
from ..const import LABEL_MACHINE, MACHINE_ID
|
||||
from ..exceptions import DockerJobError
|
||||
from ..hardware.const import PolicyGroup
|
||||
from ..homeassistant.const import LANDINGPAGE
|
||||
from ..utils import process_lock
|
||||
from ..jobs.const import JobExecutionLimit
|
||||
from ..jobs.decorator import Job
|
||||
from .const import (
|
||||
ENV_TIME,
|
||||
ENV_TOKEN,
|
||||
@@ -131,13 +133,18 @@ class DockerHomeAssistant(DockerInterface):
|
||||
|
||||
return mounts
|
||||
|
||||
async def _run(self) -> None:
|
||||
@Job(
|
||||
name="docker_home_assistant_run",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=DockerJobError,
|
||||
)
|
||||
async def run(self) -> None:
|
||||
"""Run Docker image."""
|
||||
if await self.is_running():
|
||||
return
|
||||
|
||||
# Cleanup
|
||||
await self._stop()
|
||||
await self.stop()
|
||||
|
||||
# Create & Run container
|
||||
docker_container = await self.sys_run_in_executor(
|
||||
@@ -173,7 +180,11 @@ class DockerHomeAssistant(DockerInterface):
|
||||
"Starting Home Assistant %s with version %s", self.image, self.version
|
||||
)
|
||||
|
||||
@process_lock
|
||||
@Job(
|
||||
name="docker_home_assistant_execute_command",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=DockerJobError,
|
||||
)
|
||||
async def execute_command(self, command: str) -> CommandReturn:
|
||||
"""Create a temporary container and run command."""
|
||||
return await self.sys_run_in_executor(
|
||||
|
@@ -2,12 +2,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from collections.abc import Awaitable
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
import re
|
||||
from time import time
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from awesomeversion import AwesomeVersion
|
||||
from awesomeversion.strategy import AwesomeVersionStrategy
|
||||
@@ -24,18 +26,21 @@ from ..const import (
|
||||
BusEvent,
|
||||
CpuArch,
|
||||
)
|
||||
from ..coresys import CoreSys, CoreSysAttributes
|
||||
from ..coresys import CoreSys
|
||||
from ..exceptions import (
|
||||
CodeNotaryError,
|
||||
CodeNotaryUntrusted,
|
||||
DockerAPIError,
|
||||
DockerError,
|
||||
DockerJobError,
|
||||
DockerNotFound,
|
||||
DockerRequestError,
|
||||
DockerTrustError,
|
||||
)
|
||||
from ..jobs.const import JOB_GROUP_DOCKER_INTERFACE, JobExecutionLimit
|
||||
from ..jobs.decorator import Job
|
||||
from ..jobs.job_group import JobGroup
|
||||
from ..resolution.const import ContextType, IssueType, SuggestionType
|
||||
from ..utils import process_lock
|
||||
from ..utils.sentry import capture_exception
|
||||
from .const import ContainerState, RestartPolicy
|
||||
from .manager import CommandReturn
|
||||
@@ -73,11 +78,18 @@ def _container_state_from_model(docker_container: Container) -> ContainerState:
|
||||
return ContainerState.STOPPED
|
||||
|
||||
|
||||
class DockerInterface(CoreSysAttributes):
|
||||
class DockerInterface(JobGroup):
|
||||
"""Docker Supervisor interface."""
|
||||
|
||||
def __init__(self, coresys: CoreSys):
|
||||
"""Initialize Docker base wrapper."""
|
||||
super().__init__(
|
||||
coresys,
|
||||
JOB_GROUP_DOCKER_INTERFACE.format_map(
|
||||
defaultdict(str, name=self.name or uuid4().hex)
|
||||
),
|
||||
self.name,
|
||||
)
|
||||
self.coresys: CoreSys = coresys
|
||||
self._meta: dict[str, Any] | None = None
|
||||
self.lock: asyncio.Lock = asyncio.Lock()
|
||||
@@ -204,25 +216,19 @@ class DockerInterface(CoreSysAttributes):
|
||||
|
||||
await self.sys_run_in_executor(self.sys_docker.docker.login, **credentials)
|
||||
|
||||
@process_lock
|
||||
def install(
|
||||
self,
|
||||
version: AwesomeVersion,
|
||||
image: str | None = None,
|
||||
latest: bool = False,
|
||||
arch: CpuArch | None = None,
|
||||
) -> Awaitable[None]:
|
||||
"""Pull docker image."""
|
||||
return self._install(version, image, latest, arch)
|
||||
|
||||
async def _install(
|
||||
@Job(
|
||||
name="docker_interface_install",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=DockerJobError,
|
||||
)
|
||||
async def install(
|
||||
self,
|
||||
version: AwesomeVersion,
|
||||
image: str | None = None,
|
||||
latest: bool = False,
|
||||
arch: CpuArch | None = None,
|
||||
) -> None:
|
||||
"""Pull Docker image."""
|
||||
"""Pull docker image."""
|
||||
image = image or self.image
|
||||
arch = arch or self.sys_arch.supervisor
|
||||
|
||||
@@ -328,17 +334,15 @@ class DockerInterface(CoreSysAttributes):
|
||||
|
||||
return _container_state_from_model(docker_container)
|
||||
|
||||
@process_lock
|
||||
def attach(
|
||||
@Job(
|
||||
name="docker_interface_attach",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=DockerJobError,
|
||||
)
|
||||
async def attach(
|
||||
self, version: AwesomeVersion, *, skip_state_event_if_down: bool = False
|
||||
) -> Awaitable[None]:
|
||||
"""Attach to running Docker container."""
|
||||
return self._attach(version, skip_state_event_if_down)
|
||||
|
||||
async def _attach(
|
||||
self, version: AwesomeVersion, skip_state_event_if_down: bool = False
|
||||
) -> None:
|
||||
"""Attach to running docker container."""
|
||||
"""Attach to running Docker container."""
|
||||
with suppress(docker.errors.DockerException, requests.RequestException):
|
||||
docker_container = await self.sys_run_in_executor(
|
||||
self.sys_docker.containers.get, self.name
|
||||
@@ -370,21 +374,21 @@ class DockerInterface(CoreSysAttributes):
|
||||
raise DockerError()
|
||||
_LOGGER.info("Attaching to %s with version %s", self.image, self.version)
|
||||
|
||||
@process_lock
|
||||
def run(self) -> Awaitable[None]:
|
||||
"""Run Docker image."""
|
||||
return self._run()
|
||||
|
||||
async def _run(self) -> None:
|
||||
@Job(
|
||||
name="docker_interface_run",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=DockerJobError,
|
||||
)
|
||||
async def run(self) -> None:
|
||||
"""Run Docker image."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@process_lock
|
||||
def stop(self, remove_container: bool = True) -> Awaitable[None]:
|
||||
"""Stop/remove Docker container."""
|
||||
return self._stop(remove_container)
|
||||
|
||||
async def _stop(self, remove_container: bool = True) -> None:
|
||||
@Job(
|
||||
name="docker_interface_stop",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=DockerJobError,
|
||||
)
|
||||
async def stop(self, remove_container: bool = True) -> None:
|
||||
"""Stop/remove Docker container."""
|
||||
with suppress(DockerNotFound):
|
||||
await self.sys_run_in_executor(
|
||||
@@ -394,34 +398,40 @@ class DockerInterface(CoreSysAttributes):
|
||||
remove_container,
|
||||
)
|
||||
|
||||
@process_lock
|
||||
@Job(
|
||||
name="docker_interface_start",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=DockerJobError,
|
||||
)
|
||||
def start(self) -> Awaitable[None]:
|
||||
"""Start Docker container."""
|
||||
return self.sys_run_in_executor(self.sys_docker.start_container, self.name)
|
||||
|
||||
@process_lock
|
||||
@Job(
|
||||
name="docker_interface_remove",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=DockerJobError,
|
||||
)
|
||||
async def remove(self) -> None:
|
||||
"""Remove Docker images."""
|
||||
# Cleanup container
|
||||
with suppress(DockerError):
|
||||
await self._stop()
|
||||
await self.stop()
|
||||
|
||||
await self.sys_run_in_executor(
|
||||
self.sys_docker.remove_image, self.image, self.version
|
||||
)
|
||||
self._meta = None
|
||||
|
||||
@process_lock
|
||||
def update(
|
||||
self, version: AwesomeVersion, image: str | None = None, latest: bool = False
|
||||
) -> Awaitable[None]:
|
||||
"""Update a Docker image."""
|
||||
return self._update(version, image, latest)
|
||||
|
||||
async def _update(
|
||||
@Job(
|
||||
name="docker_interface_update",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=DockerJobError,
|
||||
)
|
||||
async def update(
|
||||
self, version: AwesomeVersion, image: str | None = None, latest: bool = False
|
||||
) -> None:
|
||||
"""Update a docker image."""
|
||||
"""Update a Docker image."""
|
||||
image = image or self.image
|
||||
|
||||
_LOGGER.info(
|
||||
@@ -429,11 +439,11 @@ class DockerInterface(CoreSysAttributes):
|
||||
)
|
||||
|
||||
# Update docker image
|
||||
await self._install(version, image=image, latest=latest)
|
||||
await self.install(version, image=image, latest=latest)
|
||||
|
||||
# Stop container & cleanup
|
||||
with suppress(DockerError):
|
||||
await self._stop()
|
||||
await self.stop()
|
||||
|
||||
async def logs(self) -> bytes:
|
||||
"""Return Docker logs of container."""
|
||||
@@ -444,12 +454,12 @@ class DockerInterface(CoreSysAttributes):
|
||||
|
||||
return b""
|
||||
|
||||
@process_lock
|
||||
@Job(
|
||||
name="docker_interface_cleanup",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=DockerJobError,
|
||||
)
|
||||
def cleanup(self, old_image: str | None = None) -> Awaitable[None]:
|
||||
"""Check if old version exists and cleanup."""
|
||||
return self._cleanup(old_image)
|
||||
|
||||
def _cleanup(self, old_image: str | None = None) -> Awaitable[None]:
|
||||
"""Check if old version exists and cleanup."""
|
||||
return self.sys_run_in_executor(
|
||||
self.sys_docker.cleanup_old_images,
|
||||
@@ -458,14 +468,22 @@ class DockerInterface(CoreSysAttributes):
|
||||
{old_image} if old_image else None,
|
||||
)
|
||||
|
||||
@process_lock
|
||||
@Job(
|
||||
name="docker_interface_restart",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=DockerJobError,
|
||||
)
|
||||
def restart(self) -> Awaitable[None]:
|
||||
"""Restart docker container."""
|
||||
return self.sys_run_in_executor(
|
||||
self.sys_docker.restart_container, self.name, self.timeout
|
||||
)
|
||||
|
||||
@process_lock
|
||||
@Job(
|
||||
name="docker_interface_execute_command",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=DockerJobError,
|
||||
)
|
||||
async def execute_command(self, command: str) -> CommandReturn:
|
||||
"""Create a temporary container and run command."""
|
||||
raise NotImplementedError()
|
||||
@@ -526,7 +544,11 @@ class DockerInterface(CoreSysAttributes):
|
||||
available_version.sort(reverse=True)
|
||||
return available_version[0]
|
||||
|
||||
@process_lock
|
||||
@Job(
|
||||
name="docker_interface_run_inside",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=DockerJobError,
|
||||
)
|
||||
def run_inside(self, command: str) -> Awaitable[CommandReturn]:
|
||||
"""Execute a command inside Docker container."""
|
||||
return self.sys_run_in_executor(
|
||||
@@ -540,7 +562,11 @@ class DockerInterface(CoreSysAttributes):
|
||||
checksum = image_id.partition(":")[2]
|
||||
return await self.sys_security.verify_own_content(checksum)
|
||||
|
||||
@process_lock
|
||||
@Job(
|
||||
name="docker_interface_check_trust",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=DockerJobError,
|
||||
)
|
||||
async def check_trust(self) -> None:
|
||||
"""Check trust of exists Docker image."""
|
||||
try:
|
||||
|
@@ -2,6 +2,9 @@
|
||||
import logging
|
||||
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..exceptions import DockerJobError
|
||||
from ..jobs.const import JobExecutionLimit
|
||||
from ..jobs.decorator import Job
|
||||
from .const import ENV_TIME, Capabilities
|
||||
from .interface import DockerInterface
|
||||
|
||||
@@ -28,13 +31,18 @@ class DockerMulticast(DockerInterface, CoreSysAttributes):
|
||||
"""Generate needed capabilities."""
|
||||
return [Capabilities.NET_ADMIN.value]
|
||||
|
||||
async def _run(self) -> None:
|
||||
@Job(
|
||||
name="docker_multicast_run",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=DockerJobError,
|
||||
)
|
||||
async def run(self) -> None:
|
||||
"""Run Docker image."""
|
||||
if await self.is_running():
|
||||
return
|
||||
|
||||
# Cleanup
|
||||
await self._stop()
|
||||
await self.stop()
|
||||
|
||||
# Create & Run container
|
||||
docker_container = await self.sys_run_in_executor(
|
||||
|
@@ -3,6 +3,9 @@ import logging
|
||||
|
||||
from ..const import DOCKER_NETWORK_MASK
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..exceptions import DockerJobError
|
||||
from ..jobs.const import JobExecutionLimit
|
||||
from ..jobs.decorator import Job
|
||||
from .const import ENV_TIME, ENV_TOKEN, MOUNT_DOCKER, RestartPolicy
|
||||
from .interface import DockerInterface
|
||||
|
||||
@@ -25,13 +28,18 @@ class DockerObserver(DockerInterface, CoreSysAttributes):
|
||||
"""Return name of Docker container."""
|
||||
return OBSERVER_DOCKER_NAME
|
||||
|
||||
async def _run(self) -> None:
|
||||
@Job(
|
||||
name="docker_observer_run",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=DockerJobError,
|
||||
)
|
||||
async def run(self) -> None:
|
||||
"""Run Docker image."""
|
||||
if await self.is_running():
|
||||
return
|
||||
|
||||
# Cleanup
|
||||
await self._stop()
|
||||
await self.stop()
|
||||
|
||||
# Create & Run container
|
||||
docker_container = await self.sys_run_in_executor(
|
||||
|
@@ -9,7 +9,9 @@ import docker
|
||||
import requests
|
||||
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..exceptions import DockerError
|
||||
from ..exceptions import DockerError, DockerJobError
|
||||
from ..jobs.const import JobExecutionLimit
|
||||
from ..jobs.decorator import Job
|
||||
from .const import PropagationMode
|
||||
from .interface import DockerInterface
|
||||
|
||||
@@ -43,8 +45,13 @@ class DockerSupervisor(DockerInterface, CoreSysAttributes):
|
||||
if mount.get("Destination") == "/data"
|
||||
)
|
||||
|
||||
async def _attach(
|
||||
self, version: AwesomeVersion, skip_state_event_if_down: bool = False
|
||||
@Job(
|
||||
name="docker_supervisor_attach",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=DockerJobError,
|
||||
)
|
||||
async def attach(
|
||||
self, version: AwesomeVersion, *, skip_state_event_if_down: bool = False
|
||||
) -> None:
|
||||
"""Attach to running docker container."""
|
||||
try:
|
||||
|
@@ -36,6 +36,18 @@ class JobConditionException(JobException):
|
||||
"""Exception happening for job conditions."""
|
||||
|
||||
|
||||
class JobStartException(JobException):
|
||||
"""Exception occurred starting a job on in current asyncio task."""
|
||||
|
||||
|
||||
class JobNotFound(JobException):
|
||||
"""Exception for job not found."""
|
||||
|
||||
|
||||
class JobGroupExecutionLimitExceeded(JobException):
|
||||
"""Exception when job group execution limit exceeded."""
|
||||
|
||||
|
||||
# HomeAssistant
|
||||
|
||||
|
||||
@@ -478,6 +490,10 @@ class DockerNotFound(DockerError):
|
||||
"""Docker object don't Exists."""
|
||||
|
||||
|
||||
class DockerJobError(DockerError, JobException):
|
||||
"""Error executing docker job."""
|
||||
|
||||
|
||||
# Hardware
|
||||
|
||||
|
||||
|
@@ -32,7 +32,10 @@ class HomeAssistantAPI(CoreSysAttributes):
|
||||
self.access_token: str | None = None
|
||||
self._access_token_expires: datetime | None = None
|
||||
|
||||
@Job(limit=JobExecutionLimit.SINGLE_WAIT)
|
||||
@Job(
|
||||
name="home_assistant_api_ensure_access_token",
|
||||
limit=JobExecutionLimit.SINGLE_WAIT,
|
||||
)
|
||||
async def ensure_access_token(self) -> None:
|
||||
"""Ensure there is an access token."""
|
||||
if (
|
||||
|
@@ -11,7 +11,7 @@ import attr
|
||||
from awesomeversion import AwesomeVersion
|
||||
|
||||
from ..const import ATTR_HOMEASSISTANT, BusEvent
|
||||
from ..coresys import CoreSys, CoreSysAttributes
|
||||
from ..coresys import CoreSys
|
||||
from ..docker.const import ContainerState
|
||||
from ..docker.homeassistant import DockerHomeAssistant
|
||||
from ..docker.monitor import DockerContainerStateEvent
|
||||
@@ -22,11 +22,13 @@ from ..exceptions import (
|
||||
HomeAssistantError,
|
||||
HomeAssistantJobError,
|
||||
HomeAssistantUpdateError,
|
||||
JobException,
|
||||
)
|
||||
from ..jobs.const import JobExecutionLimit
|
||||
from ..jobs.const import JOB_GROUP_HOME_ASSISTANT_CORE, JobExecutionLimit
|
||||
from ..jobs.decorator import Job, JobCondition
|
||||
from ..jobs.job_group import JobGroup
|
||||
from ..resolution.const import ContextType, IssueType
|
||||
from ..utils import convert_to_ascii, process_lock
|
||||
from ..utils import convert_to_ascii
|
||||
from ..utils.sentry import capture_exception
|
||||
from .const import (
|
||||
LANDINGPAGE,
|
||||
@@ -49,12 +51,12 @@ class ConfigResult:
|
||||
log = attr.ib()
|
||||
|
||||
|
||||
class HomeAssistantCore(CoreSysAttributes):
|
||||
class HomeAssistantCore(JobGroup):
|
||||
"""Home Assistant core object for handle it."""
|
||||
|
||||
def __init__(self, coresys: CoreSys):
|
||||
"""Initialize Home Assistant object."""
|
||||
self.coresys: CoreSys = coresys
|
||||
super().__init__(coresys, JOB_GROUP_HOME_ASSISTANT_CORE)
|
||||
self.instance: DockerHomeAssistant = DockerHomeAssistant(coresys)
|
||||
self.lock: asyncio.Lock = asyncio.Lock()
|
||||
self._error_state: bool = False
|
||||
@@ -95,9 +97,13 @@ class HomeAssistantCore(CoreSysAttributes):
|
||||
_LOGGER.info("Starting HomeAssistant landingpage")
|
||||
if not await self.instance.is_running():
|
||||
with suppress(HomeAssistantError):
|
||||
await self._start()
|
||||
await self.start()
|
||||
|
||||
@process_lock
|
||||
@Job(
|
||||
name="home_assistant_core_install_landing_page",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=HomeAssistantJobError,
|
||||
)
|
||||
async def install_landingpage(self) -> None:
|
||||
"""Install a landing page."""
|
||||
# Try to use a preinstalled landingpage
|
||||
@@ -127,7 +133,7 @@ class HomeAssistantCore(CoreSysAttributes):
|
||||
LANDINGPAGE, image=self.sys_updater.image_homeassistant
|
||||
)
|
||||
break
|
||||
except DockerError:
|
||||
except (DockerError, JobException):
|
||||
pass
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
capture_exception(err)
|
||||
@@ -139,7 +145,11 @@ class HomeAssistantCore(CoreSysAttributes):
|
||||
self.sys_homeassistant.image = self.sys_updater.image_homeassistant
|
||||
self.sys_homeassistant.save_data()
|
||||
|
||||
@process_lock
|
||||
@Job(
|
||||
name="home_assistant_core_install",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=HomeAssistantJobError,
|
||||
)
|
||||
async def install(self) -> None:
|
||||
"""Install a landing page."""
|
||||
_LOGGER.info("Home Assistant setup")
|
||||
@@ -155,7 +165,7 @@ class HomeAssistantCore(CoreSysAttributes):
|
||||
image=self.sys_updater.image_homeassistant,
|
||||
)
|
||||
break
|
||||
except DockerError:
|
||||
except (DockerError, JobException):
|
||||
pass
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
capture_exception(err)
|
||||
@@ -171,7 +181,7 @@ class HomeAssistantCore(CoreSysAttributes):
|
||||
# finishing
|
||||
try:
|
||||
_LOGGER.info("Starting Home Assistant")
|
||||
await self._start()
|
||||
await self.start()
|
||||
except HomeAssistantError:
|
||||
_LOGGER.error("Can't start Home Assistant!")
|
||||
|
||||
@@ -179,8 +189,8 @@ class HomeAssistantCore(CoreSysAttributes):
|
||||
with suppress(DockerError):
|
||||
await self.instance.cleanup()
|
||||
|
||||
@process_lock
|
||||
@Job(
|
||||
name="home_assistant_core_update",
|
||||
conditions=[
|
||||
JobCondition.FREE_SPACE,
|
||||
JobCondition.HEALTHY,
|
||||
@@ -188,6 +198,7 @@ class HomeAssistantCore(CoreSysAttributes):
|
||||
JobCondition.PLUGINS_UPDATED,
|
||||
JobCondition.SUPERVISOR_UPDATED,
|
||||
],
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=HomeAssistantJobError,
|
||||
)
|
||||
async def update(
|
||||
@@ -231,7 +242,7 @@ class HomeAssistantCore(CoreSysAttributes):
|
||||
self.sys_homeassistant.image = self.sys_updater.image_homeassistant
|
||||
|
||||
if running:
|
||||
await self._start()
|
||||
await self.start()
|
||||
_LOGGER.info("Successfully started Home Assistant %s", to_version)
|
||||
|
||||
# Successfull - last step
|
||||
@@ -281,23 +292,11 @@ class HomeAssistantCore(CoreSysAttributes):
|
||||
self.sys_resolution.create_issue(IssueType.UPDATE_FAILED, ContextType.CORE)
|
||||
raise HomeAssistantUpdateError()
|
||||
|
||||
async def _start(self) -> None:
|
||||
"""Start Home Assistant Docker & wait."""
|
||||
# Create new API token
|
||||
self.sys_homeassistant.supervisor_token = secrets.token_hex(56)
|
||||
self.sys_homeassistant.save_data()
|
||||
|
||||
# Write audio settings
|
||||
self.sys_homeassistant.write_pulse()
|
||||
|
||||
try:
|
||||
await self.instance.run()
|
||||
except DockerError as err:
|
||||
raise HomeAssistantError() from err
|
||||
|
||||
await self._block_till_run(self.sys_homeassistant.version)
|
||||
|
||||
@process_lock
|
||||
@Job(
|
||||
name="home_assistant_core_start",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=HomeAssistantJobError,
|
||||
)
|
||||
async def start(self) -> None:
|
||||
"""Run Home Assistant docker."""
|
||||
if await self.instance.is_running():
|
||||
@@ -314,9 +313,25 @@ class HomeAssistantCore(CoreSysAttributes):
|
||||
await self._block_till_run(self.sys_homeassistant.version)
|
||||
# No Instance/Container found, extended start
|
||||
else:
|
||||
await self._start()
|
||||
# Create new API token
|
||||
self.sys_homeassistant.supervisor_token = secrets.token_hex(56)
|
||||
self.sys_homeassistant.save_data()
|
||||
|
||||
@process_lock
|
||||
# Write audio settings
|
||||
self.sys_homeassistant.write_pulse()
|
||||
|
||||
try:
|
||||
await self.instance.run()
|
||||
except DockerError as err:
|
||||
raise HomeAssistantError() from err
|
||||
|
||||
await self._block_till_run(self.sys_homeassistant.version)
|
||||
|
||||
@Job(
|
||||
name="home_assistant_core_stop",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=HomeAssistantJobError,
|
||||
)
|
||||
async def stop(self) -> None:
|
||||
"""Stop Home Assistant Docker."""
|
||||
try:
|
||||
@@ -324,7 +339,11 @@ class HomeAssistantCore(CoreSysAttributes):
|
||||
except DockerError as err:
|
||||
raise HomeAssistantError() from err
|
||||
|
||||
@process_lock
|
||||
@Job(
|
||||
name="home_assistant_core_restart",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=HomeAssistantJobError,
|
||||
)
|
||||
async def restart(self) -> None:
|
||||
"""Restart Home Assistant Docker."""
|
||||
try:
|
||||
@@ -334,12 +353,16 @@ class HomeAssistantCore(CoreSysAttributes):
|
||||
|
||||
await self._block_till_run(self.sys_homeassistant.version)
|
||||
|
||||
@process_lock
|
||||
@Job(
|
||||
name="home_assistant_core_rebuild",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=HomeAssistantJobError,
|
||||
)
|
||||
async def rebuild(self) -> None:
|
||||
"""Rebuild Home Assistant Docker container."""
|
||||
with suppress(DockerError):
|
||||
await self.instance.stop()
|
||||
await self._start()
|
||||
await self.start()
|
||||
|
||||
def logs(self) -> Awaitable[bytes]:
|
||||
"""Get HomeAssistant docker logs.
|
||||
@@ -356,10 +379,7 @@ class HomeAssistantCore(CoreSysAttributes):
|
||||
return self.instance.check_trust()
|
||||
|
||||
async def stats(self) -> DockerStats:
|
||||
"""Return stats of Home Assistant.
|
||||
|
||||
Return a coroutine.
|
||||
"""
|
||||
"""Return stats of Home Assistant."""
|
||||
try:
|
||||
return await self.instance.stats()
|
||||
except DockerError as err:
|
||||
@@ -386,9 +406,12 @@ class HomeAssistantCore(CoreSysAttributes):
|
||||
|
||||
async def check_config(self) -> ConfigResult:
|
||||
"""Run Home Assistant config check."""
|
||||
result = await self.instance.execute_command(
|
||||
"python3 -m homeassistant -c /config --script check_config"
|
||||
)
|
||||
try:
|
||||
result = await self.instance.execute_command(
|
||||
"python3 -m homeassistant -c /config --script check_config"
|
||||
)
|
||||
except DockerError as err:
|
||||
raise HomeAssistantError() from err
|
||||
|
||||
# If not valid
|
||||
if result.exit_code is None:
|
||||
@@ -431,10 +454,11 @@ class HomeAssistantCore(CoreSysAttributes):
|
||||
raise HomeAssistantCrashError()
|
||||
|
||||
@Job(
|
||||
name="home_assistant_core_repair",
|
||||
conditions=[
|
||||
JobCondition.FREE_SPACE,
|
||||
JobCondition.INTERNET_HOST,
|
||||
]
|
||||
],
|
||||
)
|
||||
async def repair(self):
|
||||
"""Repair local Home Assistant data."""
|
||||
@@ -456,6 +480,7 @@ class HomeAssistantCore(CoreSysAttributes):
|
||||
await self._restart_after_problem(event.state)
|
||||
|
||||
@Job(
|
||||
name="home_assistant_core_restart_after_problem",
|
||||
limit=JobExecutionLimit.THROTTLE_RATE_LIMIT,
|
||||
throttle_period=WATCHDOG_THROTTLE_PERIOD,
|
||||
throttle_max_calls=WATCHDOG_THROTTLE_MAX_CALLS,
|
||||
|
@@ -1,5 +1,6 @@
|
||||
"""Home Assistant control object."""
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
from ipaddress import IPv4Address
|
||||
import logging
|
||||
from pathlib import Path, PurePath
|
||||
@@ -28,6 +29,7 @@ from ..const import (
|
||||
ATTR_WATCHDOG,
|
||||
FILE_HASSIO_HOMEASSISTANT,
|
||||
BusEvent,
|
||||
IngressSessionDataUser,
|
||||
)
|
||||
from ..coresys import CoreSys, CoreSysAttributes
|
||||
from ..exceptions import (
|
||||
@@ -38,7 +40,7 @@ from ..exceptions import (
|
||||
)
|
||||
from ..hardware.const import PolicyGroup
|
||||
from ..hardware.data import Device
|
||||
from ..jobs.decorator import Job
|
||||
from ..jobs.decorator import Job, JobExecutionLimit
|
||||
from ..utils import remove_folder
|
||||
from ..utils.common import FileConfiguration
|
||||
from ..utils.json import read_json_file, write_json_file
|
||||
@@ -304,7 +306,7 @@ class HomeAssistant(FileConfiguration, CoreSysAttributes):
|
||||
|
||||
self.sys_homeassistant.websocket.send_message({ATTR_TYPE: "usb/scan"})
|
||||
|
||||
@Job()
|
||||
@Job(name="home_assistant_module_backup")
|
||||
async def backup(self, tar_file: tarfile.TarFile) -> None:
|
||||
"""Backup Home Assistant Core config/ directory."""
|
||||
|
||||
@@ -432,3 +434,21 @@ class HomeAssistant(FileConfiguration, CoreSysAttributes):
|
||||
ATTR_WATCHDOG,
|
||||
):
|
||||
self._data[attr] = data[attr]
|
||||
|
||||
@Job(
|
||||
name="home_assistant_get_users",
|
||||
limit=JobExecutionLimit.THROTTLE_WAIT,
|
||||
throttle_period=timedelta(minutes=5),
|
||||
)
|
||||
async def get_users(self) -> list[IngressSessionDataUser]:
|
||||
"""Get list of all configured users."""
|
||||
list_of_users = await self.sys_homeassistant.websocket.async_send_command(
|
||||
{ATTR_TYPE: "config/auth/list"}
|
||||
)
|
||||
|
||||
return [
|
||||
IngressSessionDataUser(
|
||||
id=data["id"], username=data["username"], display_name=data["name"]
|
||||
)
|
||||
for data in list_of_users
|
||||
]
|
||||
|
@@ -40,7 +40,11 @@ class HomeAssistantSecrets(CoreSysAttributes):
|
||||
"""Reload secrets."""
|
||||
await self._read_secrets()
|
||||
|
||||
@Job(limit=JobExecutionLimit.THROTTLE_WAIT, throttle_period=timedelta(seconds=60))
|
||||
@Job(
|
||||
name="home_assistant_secrets_read",
|
||||
limit=JobExecutionLimit.THROTTLE_WAIT,
|
||||
throttle_period=timedelta(seconds=60),
|
||||
)
|
||||
async def _read_secrets(self):
|
||||
"""Read secrets.yaml into memory."""
|
||||
if not self.path_secrets.exists():
|
||||
|
@@ -61,6 +61,7 @@ class Interface:
|
||||
|
||||
name: str
|
||||
mac: str
|
||||
path: str
|
||||
enabled: bool
|
||||
connected: bool
|
||||
primary: bool
|
||||
@@ -75,12 +76,8 @@ class Interface:
|
||||
if not inet.settings:
|
||||
return False
|
||||
|
||||
if inet.settings.device and inet.settings.device.match_device:
|
||||
matchers = inet.settings.device.match_device.split(",", 1)
|
||||
return (
|
||||
f"mac:{self.mac}" in matchers
|
||||
or f"interface-name:{self.name}" in matchers
|
||||
)
|
||||
if inet.settings.match and inet.settings.match.path:
|
||||
return inet.settings.match.path == [self.path]
|
||||
|
||||
return inet.settings.connection.interface_name == self.name
|
||||
|
||||
@@ -108,6 +105,7 @@ class Interface:
|
||||
return Interface(
|
||||
inet.name,
|
||||
inet.hw_address,
|
||||
inet.path,
|
||||
inet.settings is not None,
|
||||
Interface._map_nm_connected(inet.connection),
|
||||
inet.primary,
|
||||
|
@@ -107,7 +107,7 @@ class NetworkManager(CoreSysAttributes):
|
||||
|
||||
return Interface.from_dbus_interface(self.sys_dbus.network.get(inet_name))
|
||||
|
||||
@Job(conditions=[JobCondition.HOST_NETWORK])
|
||||
@Job(name="network_manager_load", conditions=[JobCondition.HOST_NETWORK])
|
||||
async def load(self):
|
||||
"""Load network information and reapply defaults over dbus."""
|
||||
# Apply current settings on each interface so OS can update any out of date defaults
|
||||
|
@@ -232,7 +232,11 @@ class SoundControl(CoreSysAttributes):
|
||||
await self.sys_run_in_executor(_activate_profile)
|
||||
await self.update()
|
||||
|
||||
@Job(limit=JobExecutionLimit.THROTTLE_WAIT, throttle_period=timedelta(seconds=10))
|
||||
@Job(
|
||||
name="sound_control_update",
|
||||
limit=JobExecutionLimit.THROTTLE_WAIT,
|
||||
throttle_period=timedelta(seconds=10),
|
||||
)
|
||||
async def update(self):
|
||||
"""Update properties over dbus."""
|
||||
_LOGGER.info("Updating PulseAudio information")
|
||||
|
@@ -5,7 +5,13 @@ import random
|
||||
import secrets
|
||||
|
||||
from .addons.addon import Addon
|
||||
from .const import ATTR_PORTS, ATTR_SESSION, FILE_HASSIO_INGRESS
|
||||
from .const import (
|
||||
ATTR_PORTS,
|
||||
ATTR_SESSION,
|
||||
ATTR_SESSION_DATA,
|
||||
FILE_HASSIO_INGRESS,
|
||||
IngressSessionData,
|
||||
)
|
||||
from .coresys import CoreSys, CoreSysAttributes
|
||||
from .utils import check_port
|
||||
from .utils.common import FileConfiguration
|
||||
@@ -30,11 +36,20 @@ class Ingress(FileConfiguration, CoreSysAttributes):
|
||||
return None
|
||||
return self.sys_addons.get(self.tokens[token], local_only=True)
|
||||
|
||||
def get_session_data(self, session_id: str) -> IngressSessionData | None:
|
||||
"""Return complementary data of current session or None."""
|
||||
return self.sessions_data.get(session_id)
|
||||
|
||||
@property
|
||||
def sessions(self) -> dict[str, float]:
|
||||
"""Return sessions."""
|
||||
return self._data[ATTR_SESSION]
|
||||
|
||||
@property
|
||||
def sessions_data(self) -> dict[str, IngressSessionData]:
|
||||
"""Return sessions_data."""
|
||||
return self._data[ATTR_SESSION_DATA]
|
||||
|
||||
@property
|
||||
def ports(self) -> dict[str, int]:
|
||||
"""Return list of dynamic ports."""
|
||||
@@ -71,6 +86,7 @@ class Ingress(FileConfiguration, CoreSysAttributes):
|
||||
now = utcnow()
|
||||
|
||||
sessions = {}
|
||||
sessions_data: dict[str, IngressSessionData] = {}
|
||||
for session, valid in self.sessions.items():
|
||||
# check if timestamp valid, to avoid crash on malformed timestamp
|
||||
try:
|
||||
@@ -84,10 +100,13 @@ class Ingress(FileConfiguration, CoreSysAttributes):
|
||||
|
||||
# Is valid
|
||||
sessions[session] = valid
|
||||
sessions_data[session] = self.get_session_data(session)
|
||||
|
||||
# Write back
|
||||
self.sessions.clear()
|
||||
self.sessions.update(sessions)
|
||||
self.sessions_data.clear()
|
||||
self.sessions_data.update(sessions_data)
|
||||
|
||||
def _update_token_list(self) -> None:
|
||||
"""Regenerate token <-> Add-on map."""
|
||||
@@ -97,12 +116,15 @@ class Ingress(FileConfiguration, CoreSysAttributes):
|
||||
for addon in self.addons:
|
||||
self.tokens[addon.ingress_token] = addon.slug
|
||||
|
||||
def create_session(self) -> str:
|
||||
def create_session(self, data: IngressSessionData | None = None) -> str:
|
||||
"""Create new session."""
|
||||
session = secrets.token_hex(64)
|
||||
valid = utcnow() + timedelta(minutes=15)
|
||||
|
||||
self.sessions[session] = valid.timestamp()
|
||||
if data is not None:
|
||||
self.sessions_data[session] = data
|
||||
|
||||
return session
|
||||
|
||||
def validate_session(self, session: str) -> bool:
|
||||
|
@@ -1,53 +1,70 @@
|
||||
"""Supervisor job manager."""
|
||||
from collections.abc import Callable
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar, Token
|
||||
import logging
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from attrs import define, field
|
||||
from attrs.setters import frozen
|
||||
from attrs.validators import ge, le
|
||||
|
||||
from ..coresys import CoreSys, CoreSysAttributes
|
||||
from ..exceptions import JobNotFound, JobStartException
|
||||
from ..utils.common import FileConfiguration
|
||||
from .const import ATTR_IGNORE_CONDITIONS, FILE_CONFIG_JOBS, JobCondition
|
||||
from .validate import SCHEMA_JOBS_CONFIG
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__package__)
|
||||
# Context vars only act as a global within the same asyncio task
|
||||
# When a new asyncio task is started the current context is copied over.
|
||||
# Modifications to it in one task are not visible to others though.
|
||||
# This allows us to track what job is currently in progress in each task.
|
||||
_CURRENT_JOB: ContextVar[UUID] = ContextVar("current_job")
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SupervisorJob(CoreSysAttributes):
|
||||
"""Supervisor running job class."""
|
||||
@define
|
||||
class SupervisorJob:
|
||||
"""Representation of a job running in supervisor."""
|
||||
|
||||
def __init__(self, coresys: CoreSys, name: str):
|
||||
"""Initialize the JobManager class."""
|
||||
self.coresys: CoreSys = coresys
|
||||
self.name: str = name
|
||||
self._progress: int = 0
|
||||
self._stage: str | None = None
|
||||
name: str = field(on_setattr=frozen)
|
||||
reference: str | None = None
|
||||
progress: int = field(default=0, validator=[ge(0), le(100)])
|
||||
stage: str | None = None
|
||||
uuid: UUID = field(init=False, factory=lambda: uuid4().hex, on_setattr=frozen)
|
||||
parent_id: UUID = field(
|
||||
init=False, factory=lambda: _CURRENT_JOB.get(None), on_setattr=frozen
|
||||
)
|
||||
done: bool = field(init=False, default=False)
|
||||
|
||||
@property
|
||||
def progress(self) -> int:
|
||||
"""Return the current progress."""
|
||||
return self._progress
|
||||
@contextmanager
|
||||
def start(self, *, on_done: Callable[["SupervisorJob"], None] | None = None):
|
||||
"""Start the job in the current task.
|
||||
|
||||
@property
|
||||
def stage(self) -> str | None:
|
||||
"""Return the current stage."""
|
||||
return self._stage
|
||||
This can only be called if the parent ID matches the job running in the current task.
|
||||
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.
|
||||
"""
|
||||
if self.done:
|
||||
raise JobStartException("Job is already complete")
|
||||
if _CURRENT_JOB.get(None) != self.parent_id:
|
||||
raise JobStartException("Job has a different parent from current job")
|
||||
|
||||
def update(self, progress: int | None = None, stage: str | None = None) -> None:
|
||||
"""Update the job object."""
|
||||
if progress is not None:
|
||||
if progress >= round(100):
|
||||
self.sys_jobs.remove_job(self)
|
||||
return
|
||||
self._progress = round(progress)
|
||||
if stage is not None:
|
||||
self._stage = stage
|
||||
_LOGGER.debug(
|
||||
"Job updated; name: %s, progress: %s, stage: %s",
|
||||
self.name,
|
||||
self.progress,
|
||||
self.stage,
|
||||
)
|
||||
token: Token[UUID] | None = None
|
||||
try:
|
||||
token = _CURRENT_JOB.set(self.uuid)
|
||||
yield self
|
||||
finally:
|
||||
self.done = True
|
||||
if token:
|
||||
_CURRENT_JOB.reset(token)
|
||||
if on_done:
|
||||
on_done(self)
|
||||
|
||||
|
||||
class JobManager(FileConfiguration, CoreSysAttributes):
|
||||
"""Job class."""
|
||||
"""Job Manager class."""
|
||||
|
||||
def __init__(self, coresys: CoreSys):
|
||||
"""Initialize the JobManager class."""
|
||||
@@ -58,7 +75,7 @@ class JobManager(FileConfiguration, CoreSysAttributes):
|
||||
@property
|
||||
def jobs(self) -> list[SupervisorJob]:
|
||||
"""Return a list of current jobs."""
|
||||
return self._jobs
|
||||
return list(self._jobs.values())
|
||||
|
||||
@property
|
||||
def ignore_conditions(self) -> list[JobCondition]:
|
||||
@@ -70,18 +87,30 @@ class JobManager(FileConfiguration, CoreSysAttributes):
|
||||
"""Set a list of ignored condition."""
|
||||
self._data[ATTR_IGNORE_CONDITIONS] = value
|
||||
|
||||
def get_job(self, name: str) -> SupervisorJob:
|
||||
"""Return a job, create one if it does not exist."""
|
||||
if name not in self._jobs:
|
||||
self._jobs[name] = SupervisorJob(self.coresys, name)
|
||||
def new_job(
|
||||
self, name: str, reference: str | None = None, initial_stage: str | None = None
|
||||
) -> SupervisorJob:
|
||||
"""Create a new job."""
|
||||
job = SupervisorJob(name, reference=reference, stage=initial_stage)
|
||||
self._jobs[job.uuid] = job
|
||||
return job
|
||||
|
||||
return self._jobs[name]
|
||||
def get_job(self, uuid: UUID | None = None) -> SupervisorJob | None:
|
||||
"""Return a job by uuid if it exists. Returns the current job of the asyncio task if uuid omitted."""
|
||||
if uuid:
|
||||
return self._jobs.get(uuid)
|
||||
|
||||
if uuid := _CURRENT_JOB.get(None):
|
||||
return self._jobs.get(uuid)
|
||||
|
||||
return None
|
||||
|
||||
def remove_job(self, job: SupervisorJob) -> None:
|
||||
"""Remove a job."""
|
||||
if job.name in self._jobs:
|
||||
del self._jobs[job.name]
|
||||
"""Remove a job by UUID."""
|
||||
if job.uuid not in self._jobs:
|
||||
raise JobNotFound(f"Could not find job {job.name}", _LOGGER.error)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all jobs."""
|
||||
self._jobs.clear()
|
||||
if not job.done:
|
||||
_LOGGER.warning("Removing incomplete job %s from job manager", job.name)
|
||||
|
||||
del self._jobs[job.uuid]
|
||||
|
@@ -8,6 +8,10 @@ FILE_CONFIG_JOBS = Path(SUPERVISOR_DATA, "jobs.json")
|
||||
|
||||
ATTR_IGNORE_CONDITIONS = "ignore_conditions"
|
||||
|
||||
JOB_GROUP_ADDON = "addon_{slug}"
|
||||
JOB_GROUP_DOCKER_INTERFACE = "container_{name}"
|
||||
JOB_GROUP_HOME_ASSISTANT_CORE = "home_assistant_core"
|
||||
|
||||
|
||||
class JobCondition(str, Enum):
|
||||
"""Job condition enum."""
|
||||
@@ -34,3 +38,8 @@ class JobExecutionLimit(str, Enum):
|
||||
THROTTLE = "throttle"
|
||||
THROTTLE_WAIT = "throttle_wait"
|
||||
THROTTLE_RATE_LIMIT = "throttle_rate_limit"
|
||||
GROUP_ONCE = "group_once"
|
||||
GROUP_WAIT = "group_wait"
|
||||
GROUP_THROTTLE = "group_throttle"
|
||||
GROUP_THROTTLE_WAIT = "group_throttle_wait"
|
||||
GROUP_THROTTLE_RATE_LIMIT = "group_throttle_rate_limit"
|
||||
|
@@ -8,13 +8,20 @@ from typing import Any
|
||||
|
||||
from ..const import CoreState
|
||||
from ..coresys import CoreSys, CoreSysAttributes
|
||||
from ..exceptions import HassioError, JobConditionException, JobException
|
||||
from ..exceptions import (
|
||||
HassioError,
|
||||
JobConditionException,
|
||||
JobException,
|
||||
JobGroupExecutionLimitExceeded,
|
||||
)
|
||||
from ..host.const import HostFeature
|
||||
from ..resolution.const import MINIMUM_FREE_SPACE_THRESHOLD, ContextType, IssueType
|
||||
from ..utils.sentry import capture_exception
|
||||
from .const import JobCondition, JobExecutionLimit
|
||||
from .job_group import JobGroup
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__package__)
|
||||
_JOB_NAMES: set[str] = set()
|
||||
|
||||
|
||||
class Job(CoreSysAttributes):
|
||||
@@ -22,7 +29,7 @@ class Job(CoreSysAttributes):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str | None = None,
|
||||
name: str,
|
||||
conditions: list[JobCondition] | None = None,
|
||||
cleanup: bool = True,
|
||||
on_condition: JobException | None = None,
|
||||
@@ -33,6 +40,10 @@ class Job(CoreSysAttributes):
|
||||
throttle_max_calls: int | None = None,
|
||||
):
|
||||
"""Initialize the Job class."""
|
||||
if name in _JOB_NAMES:
|
||||
raise RuntimeError(f"A job already exists with name {name}!")
|
||||
|
||||
_JOB_NAMES.add(name)
|
||||
self.name = name
|
||||
self.conditions = conditions
|
||||
self.cleanup = cleanup
|
||||
@@ -42,8 +53,8 @@ class Job(CoreSysAttributes):
|
||||
self.throttle_max_calls = throttle_max_calls
|
||||
self._lock: asyncio.Semaphore | None = None
|
||||
self._method = None
|
||||
self._last_call = datetime.min
|
||||
self._rate_limited_calls: list[datetime] | None = None
|
||||
self._last_call: dict[str | None, datetime] = {}
|
||||
self._rate_limited_calls: dict[str, list[datetime]] | None = None
|
||||
|
||||
# Validate Options
|
||||
if (
|
||||
@@ -52,19 +63,70 @@ class Job(CoreSysAttributes):
|
||||
JobExecutionLimit.THROTTLE,
|
||||
JobExecutionLimit.THROTTLE_WAIT,
|
||||
JobExecutionLimit.THROTTLE_RATE_LIMIT,
|
||||
JobExecutionLimit.GROUP_THROTTLE,
|
||||
JobExecutionLimit.GROUP_THROTTLE_WAIT,
|
||||
JobExecutionLimit.GROUP_THROTTLE_RATE_LIMIT,
|
||||
)
|
||||
and self._throttle_period is None
|
||||
):
|
||||
raise RuntimeError("Using Job without a Throttle period!")
|
||||
raise RuntimeError(
|
||||
f"Job {name} is using execution limit {limit.value} without a throttle period!"
|
||||
)
|
||||
|
||||
if self.limit == JobExecutionLimit.THROTTLE_RATE_LIMIT:
|
||||
if self.limit in (
|
||||
JobExecutionLimit.THROTTLE_RATE_LIMIT,
|
||||
JobExecutionLimit.GROUP_THROTTLE_RATE_LIMIT,
|
||||
):
|
||||
if self.throttle_max_calls is None:
|
||||
raise RuntimeError("Using rate limit without throttle max calls!")
|
||||
raise RuntimeError(
|
||||
f"Job {name} is using execution limit {limit.value} without throttle max calls!"
|
||||
)
|
||||
|
||||
self._rate_limited_calls = []
|
||||
self._rate_limited_calls = {}
|
||||
|
||||
@property
|
||||
def throttle_period(self) -> timedelta | None:
|
||||
def last_call(self, group_name: str | None = None) -> datetime:
|
||||
"""Return last call datetime."""
|
||||
return self._last_call.get(group_name, datetime.min)
|
||||
|
||||
def set_last_call(self, value: datetime, group_name: str | None = None) -> None:
|
||||
"""Set last call datetime."""
|
||||
self._last_call[group_name] = value
|
||||
|
||||
def rate_limited_calls(
|
||||
self, group_name: str | None = None
|
||||
) -> list[datetime] | None:
|
||||
"""Return rate limited calls if used."""
|
||||
if self._rate_limited_calls is None:
|
||||
return None
|
||||
|
||||
return self._rate_limited_calls.get(group_name, [])
|
||||
|
||||
def add_rate_limited_call(
|
||||
self, value: datetime, group_name: str | None = None
|
||||
) -> None:
|
||||
"""Add a rate limited call to list if used."""
|
||||
if self._rate_limited_calls is None:
|
||||
raise RuntimeError(
|
||||
f"Rate limited calls not available for limit type {self.limit}"
|
||||
)
|
||||
|
||||
if group_name in self._rate_limited_calls:
|
||||
self._rate_limited_calls[group_name].append(value)
|
||||
else:
|
||||
self._rate_limited_calls[group_name] = [value]
|
||||
|
||||
def set_rate_limited_calls(
|
||||
self, value: list[datetime], group_name: str | None = None
|
||||
) -> None:
|
||||
"""Set rate limited calls if used."""
|
||||
if self._rate_limited_calls is None:
|
||||
raise RuntimeError(
|
||||
f"Rate limited calls not available for limit type {self.limit}"
|
||||
)
|
||||
|
||||
self._rate_limited_calls[group_name] = value
|
||||
|
||||
def throttle_period(self, group_name: str | None = None) -> timedelta | None:
|
||||
"""Return throttle period."""
|
||||
if self._throttle_period is None:
|
||||
return None
|
||||
@@ -73,36 +135,59 @@ class Job(CoreSysAttributes):
|
||||
return self._throttle_period
|
||||
|
||||
return self._throttle_period(
|
||||
self.coresys, self._last_call, self._rate_limited_calls
|
||||
self.coresys,
|
||||
self.last_call(group_name),
|
||||
self.rate_limited_calls(group_name),
|
||||
)
|
||||
|
||||
def _post_init(self, args: tuple[Any]) -> None:
|
||||
def _post_init(self, obj: JobGroup | CoreSysAttributes) -> JobGroup | None:
|
||||
"""Runtime init."""
|
||||
if self.name is None:
|
||||
self.name = str(self._method.__qualname__).lower().replace(".", "_")
|
||||
|
||||
# Coresys
|
||||
try:
|
||||
self.coresys = args[0].coresys
|
||||
self.coresys = obj.coresys
|
||||
except AttributeError:
|
||||
pass
|
||||
if not self.coresys:
|
||||
raise RuntimeError(f"Job on {self.name} need to be an coresys object!")
|
||||
|
||||
# Others
|
||||
# Setup lock for limits
|
||||
if self._lock is None:
|
||||
self._lock = asyncio.Semaphore()
|
||||
|
||||
# Job groups
|
||||
if self.limit in (
|
||||
JobExecutionLimit.GROUP_ONCE,
|
||||
JobExecutionLimit.GROUP_WAIT,
|
||||
JobExecutionLimit.GROUP_THROTTLE,
|
||||
JobExecutionLimit.GROUP_THROTTLE_WAIT,
|
||||
JobExecutionLimit.GROUP_THROTTLE_RATE_LIMIT,
|
||||
):
|
||||
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
|
||||
return None
|
||||
|
||||
def __call__(self, method):
|
||||
"""Call the wrapper logic."""
|
||||
self._method = method
|
||||
|
||||
@wraps(method)
|
||||
async def wrapper(*args, **kwargs) -> Any:
|
||||
"""Wrap the method."""
|
||||
self._post_init(args)
|
||||
async def wrapper(obj: JobGroup | CoreSysAttributes, *args, **kwargs) -> Any:
|
||||
"""Wrap the method.
|
||||
|
||||
job = self.sys_jobs.get_job(self.name)
|
||||
This method must be on an instance of CoreSysAttributes. If a JOB_GROUP limit
|
||||
is used, then it must be on an instance of JobGroup.
|
||||
"""
|
||||
job_group = self._post_init(obj)
|
||||
group_name: str | None = job_group.group_name if job_group else None
|
||||
job = self.sys_jobs.new_job(
|
||||
self.name, job_group.job_reference if job_group else None
|
||||
)
|
||||
|
||||
# Handle condition
|
||||
if self.conditions:
|
||||
@@ -118,50 +203,78 @@ class Job(CoreSysAttributes):
|
||||
# Handle exection limits
|
||||
if self.limit in (JobExecutionLimit.SINGLE_WAIT, JobExecutionLimit.ONCE):
|
||||
await self._acquire_exection_limit()
|
||||
elif self.limit == JobExecutionLimit.THROTTLE:
|
||||
time_since_last_call = datetime.now() - self._last_call
|
||||
if time_since_last_call < self.throttle_period:
|
||||
elif self.limit in (
|
||||
JobExecutionLimit.GROUP_ONCE,
|
||||
JobExecutionLimit.GROUP_WAIT,
|
||||
):
|
||||
try:
|
||||
await obj.acquire(job, self.limit == JobExecutionLimit.GROUP_WAIT)
|
||||
except JobGroupExecutionLimitExceeded as err:
|
||||
if self.on_condition:
|
||||
raise self.on_condition(str(err)) from err
|
||||
raise err
|
||||
elif self.limit in (
|
||||
JobExecutionLimit.THROTTLE,
|
||||
JobExecutionLimit.GROUP_THROTTLE,
|
||||
):
|
||||
time_since_last_call = datetime.now() - self.last_call(group_name)
|
||||
if time_since_last_call < self.throttle_period(group_name):
|
||||
return
|
||||
elif self.limit == JobExecutionLimit.THROTTLE_WAIT:
|
||||
elif self.limit in (
|
||||
JobExecutionLimit.THROTTLE_WAIT,
|
||||
JobExecutionLimit.GROUP_THROTTLE_WAIT,
|
||||
):
|
||||
await self._acquire_exection_limit()
|
||||
time_since_last_call = datetime.now() - self._last_call
|
||||
if time_since_last_call < self.throttle_period:
|
||||
time_since_last_call = datetime.now() - self.last_call(group_name)
|
||||
if time_since_last_call < self.throttle_period(group_name):
|
||||
self._release_exception_limits()
|
||||
return
|
||||
elif self.limit == JobExecutionLimit.THROTTLE_RATE_LIMIT:
|
||||
elif self.limit in (
|
||||
JobExecutionLimit.THROTTLE_RATE_LIMIT,
|
||||
JobExecutionLimit.GROUP_THROTTLE_RATE_LIMIT,
|
||||
):
|
||||
# Only reprocess array when necessary (at limit)
|
||||
if len(self._rate_limited_calls) >= self.throttle_max_calls:
|
||||
self._rate_limited_calls = [
|
||||
call
|
||||
for call in self._rate_limited_calls
|
||||
if call > datetime.now() - self.throttle_period
|
||||
]
|
||||
if len(self.rate_limited_calls(group_name)) >= self.throttle_max_calls:
|
||||
self.set_rate_limited_calls(
|
||||
[
|
||||
call
|
||||
for call in self.rate_limited_calls(group_name)
|
||||
if call > datetime.now() - self.throttle_period(group_name)
|
||||
],
|
||||
group_name,
|
||||
)
|
||||
|
||||
if len(self._rate_limited_calls) >= self.throttle_max_calls:
|
||||
if len(self.rate_limited_calls(group_name)) >= self.throttle_max_calls:
|
||||
on_condition = (
|
||||
JobException if self.on_condition is None else self.on_condition
|
||||
)
|
||||
raise on_condition(
|
||||
f"Rate limit exceeded, more then {self.throttle_max_calls} calls in {self.throttle_period}",
|
||||
f"Rate limit exceeded, more then {self.throttle_max_calls} calls in {self.throttle_period(group_name)}",
|
||||
)
|
||||
|
||||
# Execute Job
|
||||
try:
|
||||
self._last_call = datetime.now()
|
||||
if self._rate_limited_calls is not None:
|
||||
self._rate_limited_calls.append(self._last_call)
|
||||
with job.start(on_done=self.sys_jobs.remove_job if self.cleanup else None):
|
||||
try:
|
||||
self.set_last_call(datetime.now(), group_name)
|
||||
if self.rate_limited_calls(group_name) is not None:
|
||||
self.add_rate_limited_call(
|
||||
self.last_call(group_name), group_name
|
||||
)
|
||||
|
||||
return await self._method(*args, **kwargs)
|
||||
except HassioError as err:
|
||||
raise err
|
||||
except Exception as err:
|
||||
_LOGGER.exception("Unhandled exception: %s", err)
|
||||
capture_exception(err)
|
||||
raise JobException() from err
|
||||
finally:
|
||||
if self.cleanup:
|
||||
self.sys_jobs.remove_job(job)
|
||||
self._release_exception_limits()
|
||||
return await self._method(obj, *args, **kwargs)
|
||||
except HassioError as err:
|
||||
raise err
|
||||
except Exception as err:
|
||||
_LOGGER.exception("Unhandled exception: %s", err)
|
||||
capture_exception(err)
|
||||
raise JobException() from err
|
||||
finally:
|
||||
self._release_exception_limits()
|
||||
if self.limit in (
|
||||
JobExecutionLimit.GROUP_ONCE,
|
||||
JobExecutionLimit.GROUP_WAIT,
|
||||
):
|
||||
obj.release()
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -283,6 +396,7 @@ class Job(CoreSysAttributes):
|
||||
JobExecutionLimit.SINGLE_WAIT,
|
||||
JobExecutionLimit.ONCE,
|
||||
JobExecutionLimit.THROTTLE_WAIT,
|
||||
JobExecutionLimit.GROUP_THROTTLE_WAIT,
|
||||
):
|
||||
return
|
||||
|
||||
@@ -300,6 +414,7 @@ class Job(CoreSysAttributes):
|
||||
JobExecutionLimit.SINGLE_WAIT,
|
||||
JobExecutionLimit.ONCE,
|
||||
JobExecutionLimit.THROTTLE_WAIT,
|
||||
JobExecutionLimit.GROUP_THROTTLE_WAIT,
|
||||
):
|
||||
return
|
||||
self._lock.release()
|
||||
|
81
supervisor/jobs/job_group.py
Normal file
81
supervisor/jobs/job_group.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Job group object."""
|
||||
|
||||
from asyncio import Lock
|
||||
|
||||
from . import SupervisorJob
|
||||
from ..coresys import CoreSys, CoreSysAttributes
|
||||
from ..exceptions import JobException, JobGroupExecutionLimitExceeded
|
||||
|
||||
|
||||
class JobGroup(CoreSysAttributes):
|
||||
"""Object with methods that require a common lock.
|
||||
|
||||
This is used in classes like our DockerInterface class. Where each method
|
||||
requires a lock as it involves some extensive I/O with Docker. But some
|
||||
methods may need to call others as a part of processing to complete a
|
||||
higher-level task and should not need to relinquish the lock in between.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, coresys: CoreSys, group_name: str, job_reference: str | None = None
|
||||
) -> None:
|
||||
"""Initialize object."""
|
||||
self.coresys: CoreSys = coresys
|
||||
self._group_name: str = group_name
|
||||
self._lock: Lock = Lock()
|
||||
self._active_job: SupervisorJob | None = None
|
||||
self._parent_jobs: list[SupervisorJob] = []
|
||||
self._job_reference: str | None = job_reference
|
||||
|
||||
@property
|
||||
def active_job(self) -> SupervisorJob | None:
|
||||
"""Get active job ID."""
|
||||
return self._active_job
|
||||
|
||||
@property
|
||||
def group_name(self) -> str:
|
||||
"""Return group name."""
|
||||
return self._group_name
|
||||
|
||||
@property
|
||||
def has_lock(self) -> bool:
|
||||
"""Return true if current task has the lock on this job group."""
|
||||
return (
|
||||
self.active_job
|
||||
and (task_job := self.sys_jobs.get_job())
|
||||
and self.active_job == task_job
|
||||
)
|
||||
|
||||
@property
|
||||
def job_reference(self) -> str | None:
|
||||
"""Return value to use as reference for all jobs created for this job group."""
|
||||
return self._job_reference
|
||||
|
||||
async def acquire(self, job: SupervisorJob, wait: bool = False) -> None:
|
||||
"""Acquire the lock for the group for the specified job."""
|
||||
# If there's another job running and we're not waiting, raise
|
||||
if self.active_job and not self.has_lock and not wait:
|
||||
raise JobGroupExecutionLimitExceeded(
|
||||
f"Another job is running for job group {self.group_name}"
|
||||
)
|
||||
|
||||
# Else if we don't have the lock, acquire it
|
||||
if not self.has_lock:
|
||||
await self._lock.acquire()
|
||||
|
||||
# Store the job ID we acquired the lock for
|
||||
if self.active_job:
|
||||
self._parent_jobs.append(self.active_job)
|
||||
|
||||
self._active_job = job
|
||||
|
||||
def release(self) -> None:
|
||||
"""Release the lock for the group or return it to parent."""
|
||||
if not self.has_lock:
|
||||
raise JobException("Cannot release as caller does not own lock")
|
||||
|
||||
if self._parent_jobs:
|
||||
self._active_job = self._parent_jobs.pop()
|
||||
else:
|
||||
self._active_job = None
|
||||
self._lock.release()
|
@@ -28,15 +28,13 @@ RUN_RELOAD_BACKUPS = 72000
|
||||
RUN_RELOAD_HOST = 7600
|
||||
RUN_RELOAD_UPDATER = 7200
|
||||
RUN_RELOAD_INGRESS = 930
|
||||
RUN_RELOAD_MOUNTS = 900
|
||||
|
||||
RUN_WATCHDOG_HOMEASSISTANT_API = 120
|
||||
|
||||
RUN_WATCHDOG_ADDON_APPLICATON = 120
|
||||
RUN_WATCHDOG_OBSERVER_APPLICATION = 180
|
||||
|
||||
RUN_REFRESH_ADDON = 15
|
||||
RUN_REFRESH_MOUNTS = 900
|
||||
|
||||
PLUGIN_AUTO_UPDATE_CONDITIONS = PLUGIN_UPDATE_CONDITIONS + [JobCondition.RUNNING]
|
||||
|
||||
|
||||
@@ -65,7 +63,7 @@ class Tasks(CoreSysAttributes):
|
||||
self.sys_scheduler.register_task(self.sys_backups.reload, RUN_RELOAD_BACKUPS)
|
||||
self.sys_scheduler.register_task(self.sys_host.reload, RUN_RELOAD_HOST)
|
||||
self.sys_scheduler.register_task(self.sys_ingress.reload, RUN_RELOAD_INGRESS)
|
||||
self.sys_scheduler.register_task(self.sys_mounts.reload, RUN_REFRESH_MOUNTS)
|
||||
self.sys_scheduler.register_task(self.sys_mounts.reload, RUN_RELOAD_MOUNTS)
|
||||
|
||||
# Watchdog
|
||||
self.sys_scheduler.register_task(
|
||||
@@ -78,12 +76,12 @@ class Tasks(CoreSysAttributes):
|
||||
self._watchdog_addon_application, RUN_WATCHDOG_ADDON_APPLICATON
|
||||
)
|
||||
|
||||
# Refresh
|
||||
self.sys_scheduler.register_task(self._refresh_addon, RUN_REFRESH_ADDON)
|
||||
|
||||
_LOGGER.info("All core tasks are scheduled")
|
||||
|
||||
@Job(conditions=ADDON_UPDATE_CONDITIONS + [JobCondition.RUNNING])
|
||||
@Job(
|
||||
name="tasks_update_addons",
|
||||
conditions=ADDON_UPDATE_CONDITIONS + [JobCondition.RUNNING],
|
||||
)
|
||||
async def _update_addons(self):
|
||||
"""Check if an update is available for an Add-on and update it."""
|
||||
start_tasks: list[Awaitable[None]] = []
|
||||
@@ -112,13 +110,14 @@ class Tasks(CoreSysAttributes):
|
||||
await asyncio.gather(*start_tasks)
|
||||
|
||||
@Job(
|
||||
name="tasks_update_supervisor",
|
||||
conditions=[
|
||||
JobCondition.AUTO_UPDATE,
|
||||
JobCondition.FREE_SPACE,
|
||||
JobCondition.HEALTHY,
|
||||
JobCondition.INTERNET_HOST,
|
||||
JobCondition.RUNNING,
|
||||
]
|
||||
],
|
||||
)
|
||||
async def _update_supervisor(self):
|
||||
"""Check and run update of Supervisor Supervisor."""
|
||||
@@ -172,7 +171,7 @@ class Tasks(CoreSysAttributes):
|
||||
finally:
|
||||
self._cache[HASS_WATCHDOG_API] = 0
|
||||
|
||||
@Job(conditions=PLUGIN_AUTO_UPDATE_CONDITIONS)
|
||||
@Job(name="tasks_update_cli", conditions=PLUGIN_AUTO_UPDATE_CONDITIONS)
|
||||
async def _update_cli(self):
|
||||
"""Check and run update of cli."""
|
||||
if not self.sys_plugins.cli.need_update:
|
||||
@@ -183,7 +182,7 @@ class Tasks(CoreSysAttributes):
|
||||
)
|
||||
await self.sys_plugins.cli.update()
|
||||
|
||||
@Job(conditions=PLUGIN_AUTO_UPDATE_CONDITIONS)
|
||||
@Job(name="tasks_update_dns", conditions=PLUGIN_AUTO_UPDATE_CONDITIONS)
|
||||
async def _update_dns(self):
|
||||
"""Check and run update of CoreDNS plugin."""
|
||||
if not self.sys_plugins.dns.need_update:
|
||||
@@ -195,7 +194,7 @@ class Tasks(CoreSysAttributes):
|
||||
)
|
||||
await self.sys_plugins.dns.update()
|
||||
|
||||
@Job(conditions=PLUGIN_AUTO_UPDATE_CONDITIONS)
|
||||
@Job(name="tasks_update_audio", conditions=PLUGIN_AUTO_UPDATE_CONDITIONS)
|
||||
async def _update_audio(self):
|
||||
"""Check and run update of PulseAudio plugin."""
|
||||
if not self.sys_plugins.audio.need_update:
|
||||
@@ -207,7 +206,7 @@ class Tasks(CoreSysAttributes):
|
||||
)
|
||||
await self.sys_plugins.audio.update()
|
||||
|
||||
@Job(conditions=PLUGIN_AUTO_UPDATE_CONDITIONS)
|
||||
@Job(name="tasks_update_observer", conditions=PLUGIN_AUTO_UPDATE_CONDITIONS)
|
||||
async def _update_observer(self):
|
||||
"""Check and run update of Observer plugin."""
|
||||
if not self.sys_plugins.observer.need_update:
|
||||
@@ -219,7 +218,7 @@ class Tasks(CoreSysAttributes):
|
||||
)
|
||||
await self.sys_plugins.observer.update()
|
||||
|
||||
@Job(conditions=PLUGIN_AUTO_UPDATE_CONDITIONS)
|
||||
@Job(name="tasks_update_multicast", conditions=PLUGIN_AUTO_UPDATE_CONDITIONS)
|
||||
async def _update_multicast(self):
|
||||
"""Check and run update of multicast."""
|
||||
if not self.sys_plugins.multicast.need_update:
|
||||
@@ -278,21 +277,7 @@ class Tasks(CoreSysAttributes):
|
||||
finally:
|
||||
self._cache[addon.slug] = 0
|
||||
|
||||
async def _refresh_addon(self) -> None:
|
||||
"""Refresh addon state."""
|
||||
for addon in self.sys_addons.installed:
|
||||
# if watchdog need looking for
|
||||
if addon.watchdog or addon.state != AddonState.STARTED:
|
||||
continue
|
||||
|
||||
# if Addon have running actions
|
||||
if addon.in_progress or await addon.is_running():
|
||||
continue
|
||||
|
||||
# Adjust state
|
||||
addon.state = AddonState.STOPPED
|
||||
|
||||
@Job(conditions=[JobCondition.SUPERVISOR_UPDATED])
|
||||
@Job(name="tasks_reload_store", conditions=[JobCondition.SUPERVISOR_UPDATED])
|
||||
async def _reload_store(self) -> None:
|
||||
"""Reload store and check for addon updates."""
|
||||
await self.sys_store.reload()
|
||||
|
@@ -139,7 +139,7 @@ class MountManager(FileConfiguration, CoreSysAttributes):
|
||||
]
|
||||
)
|
||||
|
||||
@Job(conditions=[JobCondition.MOUNT_AVAILABLE])
|
||||
@Job(name="mount_manager_reload", conditions=[JobCondition.MOUNT_AVAILABLE])
|
||||
async def reload(self) -> None:
|
||||
"""Update mounts info via dbus and reload failed mounts."""
|
||||
if not self.mounts:
|
||||
@@ -180,9 +180,17 @@ class MountManager(FileConfiguration, CoreSysAttributes):
|
||||
],
|
||||
)
|
||||
|
||||
@Job(conditions=[JobCondition.MOUNT_AVAILABLE], on_condition=MountJobError)
|
||||
@Job(
|
||||
name="mount_manager_create_mount",
|
||||
conditions=[JobCondition.MOUNT_AVAILABLE],
|
||||
on_condition=MountJobError,
|
||||
)
|
||||
async def create_mount(self, mount: Mount) -> None:
|
||||
"""Add/update a mount."""
|
||||
# Add mount name to job
|
||||
if job := self.sys_jobs.get_job():
|
||||
job.reference = mount.name
|
||||
|
||||
if mount.name in self._mounts:
|
||||
_LOGGER.debug("Mount '%s' exists, unmounting then mounting from new config")
|
||||
await self.remove_mount(mount.name, retain_entry=True)
|
||||
@@ -200,9 +208,17 @@ class MountManager(FileConfiguration, CoreSysAttributes):
|
||||
elif mount.usage == MountUsage.SHARE:
|
||||
await self._bind_share(mount)
|
||||
|
||||
@Job(conditions=[JobCondition.MOUNT_AVAILABLE], on_condition=MountJobError)
|
||||
@Job(
|
||||
name="mount_manager_remove_mount",
|
||||
conditions=[JobCondition.MOUNT_AVAILABLE],
|
||||
on_condition=MountJobError,
|
||||
)
|
||||
async def remove_mount(self, name: str, *, retain_entry: bool = False) -> None:
|
||||
"""Remove a mount."""
|
||||
# Add mount name to job
|
||||
if job := self.sys_jobs.get_job():
|
||||
job.reference = name
|
||||
|
||||
if name not in self._mounts:
|
||||
raise MountNotFound(
|
||||
f"Cannot remove '{name}', no mount exists with that name"
|
||||
@@ -223,9 +239,17 @@ class MountManager(FileConfiguration, CoreSysAttributes):
|
||||
|
||||
return mount
|
||||
|
||||
@Job(conditions=[JobCondition.MOUNT_AVAILABLE], on_condition=MountJobError)
|
||||
@Job(
|
||||
name="mount_manager_reload_mount",
|
||||
conditions=[JobCondition.MOUNT_AVAILABLE],
|
||||
on_condition=MountJobError,
|
||||
)
|
||||
async def reload_mount(self, name: str) -> None:
|
||||
"""Reload a mount to retry mounting with same config."""
|
||||
# Add mount name to job
|
||||
if job := self.sys_jobs.get_job():
|
||||
job.reference = name
|
||||
|
||||
if name not in self._mounts:
|
||||
raise MountNotFound(
|
||||
f"Cannot reload '{name}', no mount exists with that name"
|
||||
|
@@ -165,7 +165,7 @@ class DataDisk(CoreSysAttributes):
|
||||
if block.drive == drive.object_path
|
||||
]
|
||||
|
||||
@Job(conditions=[JobCondition.OS_AGENT])
|
||||
@Job(name="data_disk_load", conditions=[JobCondition.OS_AGENT])
|
||||
async def load(self) -> None:
|
||||
"""Load DataDisk feature."""
|
||||
# Update datadisk details on OS-Agent
|
||||
@@ -173,6 +173,7 @@ class DataDisk(CoreSysAttributes):
|
||||
await self.sys_dbus.agent.datadisk.reload_device()
|
||||
|
||||
@Job(
|
||||
name="data_disk_migrate",
|
||||
conditions=[JobCondition.HAOS, JobCondition.OS_AGENT, JobCondition.HEALTHY],
|
||||
limit=JobExecutionLimit.ONCE,
|
||||
on_condition=HassOSJobError,
|
||||
|
@@ -156,6 +156,7 @@ class OSManager(CoreSysAttributes):
|
||||
)
|
||||
|
||||
@Job(
|
||||
name="os_manager_config_sync",
|
||||
conditions=[JobCondition.HAOS],
|
||||
on_condition=HassOSJobError,
|
||||
)
|
||||
@@ -170,6 +171,7 @@ class OSManager(CoreSysAttributes):
|
||||
await self.sys_host.services.restart("hassos-config.service")
|
||||
|
||||
@Job(
|
||||
name="os_manager_update",
|
||||
conditions=[
|
||||
JobCondition.HAOS,
|
||||
JobCondition.INTERNET_SYSTEM,
|
||||
@@ -225,7 +227,7 @@ class OSManager(CoreSysAttributes):
|
||||
)
|
||||
raise HassOSUpdateError()
|
||||
|
||||
@Job(conditions=[JobCondition.HAOS])
|
||||
@Job(name="os_manager_mark_healthy", conditions=[JobCondition.HAOS])
|
||||
async def mark_healthy(self) -> None:
|
||||
"""Set booted partition as good for rauc."""
|
||||
try:
|
||||
|
@@ -118,6 +118,7 @@ class PluginAudio(PluginBase):
|
||||
self.save_data()
|
||||
|
||||
@Job(
|
||||
name="plugin_audio_update",
|
||||
conditions=PLUGIN_UPDATE_CONDITIONS,
|
||||
on_condition=AudioJobError,
|
||||
)
|
||||
@@ -218,6 +219,7 @@ class PluginAudio(PluginBase):
|
||||
) from err
|
||||
|
||||
@Job(
|
||||
name="plugin_audio_restart_after_problem",
|
||||
limit=JobExecutionLimit.THROTTLE_RATE_LIMIT,
|
||||
throttle_period=WATCHDOG_THROTTLE_PERIOD,
|
||||
throttle_max_calls=WATCHDOG_THROTTLE_MAX_CALLS,
|
||||
|
@@ -75,6 +75,7 @@ class PluginCli(PluginBase):
|
||||
self.save_data()
|
||||
|
||||
@Job(
|
||||
name="plugin_cli_update",
|
||||
conditions=PLUGIN_UPDATE_CONDITIONS,
|
||||
on_condition=CliJobError,
|
||||
)
|
||||
@@ -151,6 +152,7 @@ class PluginCli(PluginBase):
|
||||
capture_exception(err)
|
||||
|
||||
@Job(
|
||||
name="plugin_cli_restart_after_problem",
|
||||
limit=JobExecutionLimit.THROTTLE_RATE_LIMIT,
|
||||
throttle_period=WATCHDOG_THROTTLE_PERIOD,
|
||||
throttle_max_calls=WATCHDOG_THROTTLE_MAX_CALLS,
|
||||
|
@@ -187,6 +187,7 @@ class PluginDns(PluginBase):
|
||||
await self.write_hosts()
|
||||
|
||||
@Job(
|
||||
name="plugin_dns_update",
|
||||
conditions=PLUGIN_UPDATE_CONDITIONS,
|
||||
on_condition=CoreDNSJobError,
|
||||
)
|
||||
@@ -269,6 +270,7 @@ class PluginDns(PluginBase):
|
||||
return await super().watchdog_container(event)
|
||||
|
||||
@Job(
|
||||
name="plugin_dns_restart_after_problem",
|
||||
limit=JobExecutionLimit.THROTTLE_RATE_LIMIT,
|
||||
throttle_period=WATCHDOG_THROTTLE_PERIOD,
|
||||
throttle_max_calls=WATCHDOG_THROTTLE_MAX_CALLS,
|
||||
|
@@ -71,6 +71,7 @@ class PluginMulticast(PluginBase):
|
||||
self.save_data()
|
||||
|
||||
@Job(
|
||||
name="plugin_multicast_update",
|
||||
conditions=PLUGIN_UPDATE_CONDITIONS,
|
||||
on_condition=MulticastJobError,
|
||||
)
|
||||
@@ -146,6 +147,7 @@ class PluginMulticast(PluginBase):
|
||||
capture_exception(err)
|
||||
|
||||
@Job(
|
||||
name="plugin_multicast_restart_after_problem",
|
||||
limit=JobExecutionLimit.THROTTLE_RATE_LIMIT,
|
||||
throttle_period=WATCHDOG_THROTTLE_PERIOD,
|
||||
throttle_max_calls=WATCHDOG_THROTTLE_MAX_CALLS,
|
||||
|
@@ -79,6 +79,7 @@ class PluginObserver(PluginBase):
|
||||
self.save_data()
|
||||
|
||||
@Job(
|
||||
name="plugin_observer_update",
|
||||
conditions=PLUGIN_UPDATE_CONDITIONS,
|
||||
on_condition=ObserverJobError,
|
||||
)
|
||||
@@ -156,6 +157,7 @@ class PluginObserver(PluginBase):
|
||||
capture_exception(err)
|
||||
|
||||
@Job(
|
||||
name="plugin_observer_restart_after_problem",
|
||||
limit=JobExecutionLimit.THROTTLE_RATE_LIMIT,
|
||||
throttle_period=WATCHDOG_THROTTLE_PERIOD,
|
||||
throttle_max_calls=WATCHDOG_THROTTLE_MAX_CALLS,
|
||||
|
@@ -22,6 +22,7 @@ class CheckAddonPwned(CheckBase):
|
||||
"""CheckAddonPwned class for check."""
|
||||
|
||||
@Job(
|
||||
name="check_addon_pwned_run",
|
||||
conditions=[JobCondition.INTERNET_SYSTEM],
|
||||
limit=JobExecutionLimit.THROTTLE,
|
||||
throttle_period=timedelta(hours=24),
|
||||
@@ -62,7 +63,7 @@ class CheckAddonPwned(CheckBase):
|
||||
except PwnedError:
|
||||
pass
|
||||
|
||||
@Job(conditions=[JobCondition.INTERNET_SYSTEM])
|
||||
@Job(name="check_addon_pwned_approve", conditions=[JobCondition.INTERNET_SYSTEM])
|
||||
async def approve_check(self, reference: str | None = None) -> bool:
|
||||
"""Approve check if it is affected by issue."""
|
||||
addon = self.sys_addons.get(reference)
|
||||
|
@@ -23,6 +23,7 @@ class CheckDNSServer(CheckBase):
|
||||
"""CheckDNSServer class for check."""
|
||||
|
||||
@Job(
|
||||
name="check_dns_server_run",
|
||||
conditions=[JobCondition.INTERNET_SYSTEM],
|
||||
limit=JobExecutionLimit.THROTTLE,
|
||||
throttle_period=timedelta(hours=24),
|
||||
@@ -42,7 +43,7 @@ class CheckDNSServer(CheckBase):
|
||||
)
|
||||
capture_exception(results[i])
|
||||
|
||||
@Job(conditions=[JobCondition.INTERNET_SYSTEM])
|
||||
@Job(name="check_dns_server_approve", conditions=[JobCondition.INTERNET_SYSTEM])
|
||||
async def approve_check(self, reference: str | None = None) -> bool:
|
||||
"""Approve check if it is affected by issue."""
|
||||
if reference not in self.dns_servers:
|
||||
|
@@ -23,6 +23,7 @@ class CheckDNSServerIPv6(CheckBase):
|
||||
"""CheckDNSServerIPv6 class for check."""
|
||||
|
||||
@Job(
|
||||
name="check_dns_server_ipv6_run",
|
||||
conditions=[JobCondition.INTERNET_SYSTEM],
|
||||
limit=JobExecutionLimit.THROTTLE,
|
||||
throttle_period=timedelta(hours=24),
|
||||
@@ -47,7 +48,9 @@ class CheckDNSServerIPv6(CheckBase):
|
||||
)
|
||||
capture_exception(results[i])
|
||||
|
||||
@Job(conditions=[JobCondition.INTERNET_SYSTEM])
|
||||
@Job(
|
||||
name="check_dns_server_ipv6_approve", conditions=[JobCondition.INTERNET_SYSTEM]
|
||||
)
|
||||
async def approve_check(self, reference: str | None = None) -> bool:
|
||||
"""Approve check if it is affected by issue."""
|
||||
if reference not in self.dns_servers:
|
||||
|
@@ -36,7 +36,10 @@ class ResolutionFixup(CoreSysAttributes):
|
||||
"""Return a list of all fixups."""
|
||||
return list(self._fixups.values())
|
||||
|
||||
@Job(conditions=[JobCondition.HEALTHY, JobCondition.RUNNING])
|
||||
@Job(
|
||||
name="resolution_fixup_run_autofix",
|
||||
conditions=[JobCondition.HEALTHY, JobCondition.RUNNING],
|
||||
)
|
||||
async def run_autofix(self) -> None:
|
||||
"""Run all startup fixes."""
|
||||
_LOGGER.info("Starting system autofix at state %s", self.sys_core.state)
|
||||
|
@@ -25,6 +25,7 @@ class FixupStoreExecuteReload(FixupBase):
|
||||
"""Storage class for fixup."""
|
||||
|
||||
@Job(
|
||||
name="fixup_store_execute_reload_process",
|
||||
conditions=[JobCondition.INTERNET_SYSTEM, JobCondition.FREE_SPACE],
|
||||
on_condition=ResolutionFixupJobError,
|
||||
)
|
||||
|
@@ -26,6 +26,7 @@ class FixupStoreExecuteReset(FixupBase):
|
||||
"""Storage class for fixup."""
|
||||
|
||||
@Job(
|
||||
name="fixup_store_execute_reset_process",
|
||||
conditions=[JobCondition.INTERNET_SYSTEM, JobCondition.FREE_SPACE],
|
||||
on_condition=ResolutionFixupJobError,
|
||||
)
|
||||
|
@@ -22,6 +22,7 @@ class FixupSystemExecuteIntegrity(FixupBase):
|
||||
"""Storage class for fixup."""
|
||||
|
||||
@Job(
|
||||
name="fixup_system_execute_integrity_process",
|
||||
conditions=[JobCondition.INTERNET_SYSTEM],
|
||||
on_condition=ResolutionFixupJobError,
|
||||
limit=JobExecutionLimit.THROTTLE,
|
||||
|
@@ -103,6 +103,7 @@ class Security(FileConfiguration, CoreSysAttributes):
|
||||
return
|
||||
|
||||
@Job(
|
||||
name="security_manager_integrity_check",
|
||||
conditions=[JobCondition.INTERNET_SYSTEM],
|
||||
on_condition=SecurityJobError,
|
||||
limit=JobExecutionLimit.ONCE,
|
||||
|
@@ -80,7 +80,11 @@ class StoreManager(CoreSysAttributes, FileConfiguration):
|
||||
self._data[ATTR_REPOSITORIES], add_with_errors=True
|
||||
)
|
||||
|
||||
@Job(conditions=[JobCondition.SUPERVISOR_UPDATED], on_condition=StoreJobError)
|
||||
@Job(
|
||||
name="store_manager_reload",
|
||||
conditions=[JobCondition.SUPERVISOR_UPDATED],
|
||||
on_condition=StoreJobError,
|
||||
)
|
||||
async def reload(self) -> None:
|
||||
"""Update add-ons from repository and reload list."""
|
||||
tasks = [self.sys_create_task(repository.update()) for repository in self.all]
|
||||
@@ -92,6 +96,7 @@ class StoreManager(CoreSysAttributes, FileConfiguration):
|
||||
self._read_addons()
|
||||
|
||||
@Job(
|
||||
name="store_manager_add_repository",
|
||||
conditions=[JobCondition.INTERNET_SYSTEM, JobCondition.SUPERVISOR_UPDATED],
|
||||
on_condition=StoreJobError,
|
||||
)
|
||||
|
@@ -1,5 +1,5 @@
|
||||
"""Init file for Supervisor add-on data."""
|
||||
from collections.abc import Awaitable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -29,6 +29,72 @@ from .validate import SCHEMA_REPOSITORY_CONFIG
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ProcessedRepository:
|
||||
"""Representation of a repository processed from its git folder."""
|
||||
|
||||
slug: str
|
||||
path: Path
|
||||
config: dict[str, Any]
|
||||
|
||||
|
||||
def _read_addon_translations(addon_path: Path) -> dict:
|
||||
"""Read translations from add-ons folder.
|
||||
|
||||
Should be run in the executor.
|
||||
"""
|
||||
translations_dir = addon_path / "translations"
|
||||
translations = {}
|
||||
|
||||
if not translations_dir.exists():
|
||||
return translations
|
||||
|
||||
translation_files = [
|
||||
translation
|
||||
for translation in translations_dir.glob("*")
|
||||
if translation.suffix in FILE_SUFFIX_CONFIGURATION
|
||||
]
|
||||
|
||||
for translation in translation_files:
|
||||
try:
|
||||
translations[translation.stem] = SCHEMA_ADDON_TRANSLATIONS(
|
||||
read_json_or_yaml_file(translation)
|
||||
)
|
||||
|
||||
except (ConfigurationFileError, vol.Invalid) as err:
|
||||
_LOGGER.warning("Can't read translations from %s - %s", translation, err)
|
||||
continue
|
||||
|
||||
return translations
|
||||
|
||||
|
||||
def _read_git_repository(path: Path) -> ProcessedRepository | None:
|
||||
"""Process a custom repository folder."""
|
||||
slug = extract_hash_from_path(path)
|
||||
|
||||
# exists repository json
|
||||
try:
|
||||
repository_file = find_one_filetype(
|
||||
path, "repository", FILE_SUFFIX_CONFIGURATION
|
||||
)
|
||||
except ConfigurationFileError:
|
||||
_LOGGER.warning("No repository information exists at %s", path)
|
||||
return None
|
||||
|
||||
try:
|
||||
return ProcessedRepository(
|
||||
slug,
|
||||
path,
|
||||
SCHEMA_REPOSITORY_CONFIG(read_json_or_yaml_file(repository_file)),
|
||||
)
|
||||
except ConfigurationFileError:
|
||||
_LOGGER.warning("Can't read repository information from %s", repository_file)
|
||||
return None
|
||||
except vol.Invalid:
|
||||
_LOGGER.warning("Repository parse error %s", repository_file)
|
||||
return None
|
||||
|
||||
|
||||
class StoreData(CoreSysAttributes):
|
||||
"""Hold data for Add-ons inside Supervisor."""
|
||||
|
||||
@@ -38,63 +104,43 @@ class StoreData(CoreSysAttributes):
|
||||
self.repositories: dict[str, Any] = {}
|
||||
self.addons: dict[str, Any] = {}
|
||||
|
||||
async def update(self) -> Awaitable[None]:
|
||||
async def update(self) -> None:
|
||||
"""Read data from add-on repository."""
|
||||
return await self.sys_run_in_executor(self._update)
|
||||
|
||||
def _update(self) -> None:
|
||||
self.repositories.clear()
|
||||
self.addons.clear()
|
||||
|
||||
# read core repository
|
||||
self._read_addons_folder(self.sys_config.path_addons_core, REPOSITORY_CORE)
|
||||
await self._read_addons_folder(
|
||||
self.sys_config.path_addons_core, REPOSITORY_CORE
|
||||
)
|
||||
|
||||
# read local repository
|
||||
self._read_addons_folder(self.sys_config.path_addons_local, REPOSITORY_LOCAL)
|
||||
await self._read_addons_folder(
|
||||
self.sys_config.path_addons_local, REPOSITORY_LOCAL
|
||||
)
|
||||
|
||||
# add built-in repositories information
|
||||
self._set_builtin_repositories()
|
||||
await self._set_builtin_repositories()
|
||||
|
||||
# read custom git repositories
|
||||
for repository_element in self.sys_config.path_addons_git.iterdir():
|
||||
if repository_element.is_dir():
|
||||
self._read_git_repository(repository_element)
|
||||
def _read_git_repositories() -> list[ProcessedRepository]:
|
||||
return [
|
||||
repo
|
||||
for repository_element in self.sys_config.path_addons_git.iterdir()
|
||||
if repository_element.is_dir()
|
||||
and (repo := _read_git_repository(repository_element))
|
||||
]
|
||||
|
||||
def _read_git_repository(self, path: Path) -> None:
|
||||
"""Process a custom repository folder."""
|
||||
slug = extract_hash_from_path(path)
|
||||
for repo in await self.sys_run_in_executor(_read_git_repositories):
|
||||
self.repositories[repo.slug] = repo.config
|
||||
await self._read_addons_folder(repo.path, repo.slug)
|
||||
|
||||
# exists repository json
|
||||
try:
|
||||
repository_file = find_one_filetype(
|
||||
path, "repository", FILE_SUFFIX_CONFIGURATION
|
||||
)
|
||||
except ConfigurationFileError:
|
||||
_LOGGER.warning("No repository information exists at %s", path)
|
||||
return
|
||||
|
||||
try:
|
||||
repository_info = SCHEMA_REPOSITORY_CONFIG(
|
||||
read_json_or_yaml_file(repository_file)
|
||||
)
|
||||
except ConfigurationFileError:
|
||||
_LOGGER.warning(
|
||||
"Can't read repository information from %s", repository_file
|
||||
)
|
||||
return
|
||||
except vol.Invalid:
|
||||
_LOGGER.warning("Repository parse error %s", repository_file)
|
||||
return
|
||||
|
||||
# process data
|
||||
self.repositories[slug] = repository_info
|
||||
self._read_addons_folder(path, slug)
|
||||
|
||||
def _find_addons(self, path: Path, repository: dict) -> list[Path] | None:
|
||||
async def _find_addons(self, path: Path, repository: dict) -> list[Path] | None:
|
||||
"""Find add-ons in the path."""
|
||||
try:
|
||||
|
||||
def _get_addons_list() -> list[Path]:
|
||||
# Generate a list without artefact, safe for corruptions
|
||||
addon_list = [
|
||||
return [
|
||||
addon
|
||||
for addon in path.glob("**/config.*")
|
||||
if not [
|
||||
@@ -104,6 +150,9 @@ class StoreData(CoreSysAttributes):
|
||||
]
|
||||
and addon.suffix in FILE_SUFFIX_CONFIGURATION
|
||||
]
|
||||
|
||||
try:
|
||||
addon_list = await self.sys_run_in_executor(_get_addons_list)
|
||||
except OSError as err:
|
||||
suggestion = None
|
||||
if path.stem != StoreType.LOCAL:
|
||||
@@ -120,77 +169,59 @@ class StoreData(CoreSysAttributes):
|
||||
return None
|
||||
return addon_list
|
||||
|
||||
def _read_addons_folder(self, path: Path, repository: dict) -> None:
|
||||
async def _read_addons_folder(self, path: Path, repository: str) -> None:
|
||||
"""Read data from add-ons folder."""
|
||||
if not (addon_list := self._find_addons(path, repository)):
|
||||
if not (addon_list := await self._find_addons(path, repository)):
|
||||
return
|
||||
|
||||
for addon in addon_list:
|
||||
try:
|
||||
addon_config = read_json_or_yaml_file(addon)
|
||||
except ConfigurationFileError:
|
||||
_LOGGER.warning("Can't read %s from repository %s", addon, repository)
|
||||
continue
|
||||
def _process_addons_config() -> dict[str, dict[str, Any]]:
|
||||
addons_config: dict[str, dict[str, Any]] = {}
|
||||
for addon in addon_list:
|
||||
try:
|
||||
addon_config = read_json_or_yaml_file(addon)
|
||||
except ConfigurationFileError:
|
||||
_LOGGER.warning(
|
||||
"Can't read %s from repository %s", addon, repository
|
||||
)
|
||||
continue
|
||||
|
||||
# validate
|
||||
try:
|
||||
addon_config = SCHEMA_ADDON_CONFIG(addon_config)
|
||||
except vol.Invalid as ex:
|
||||
_LOGGER.warning(
|
||||
"Can't read %s: %s", addon, humanize_error(addon_config, ex)
|
||||
)
|
||||
continue
|
||||
# validate
|
||||
try:
|
||||
addon_config = SCHEMA_ADDON_CONFIG(addon_config)
|
||||
except vol.Invalid as ex:
|
||||
_LOGGER.warning(
|
||||
"Can't read %s: %s", addon, humanize_error(addon_config, ex)
|
||||
)
|
||||
continue
|
||||
|
||||
# Generate slug
|
||||
addon_slug = f"{repository}_{addon_config[ATTR_SLUG]}"
|
||||
# Generate slug
|
||||
addon_slug = f"{repository}_{addon_config[ATTR_SLUG]}"
|
||||
|
||||
# store
|
||||
addon_config[ATTR_REPOSITORY] = repository
|
||||
addon_config[ATTR_LOCATON] = str(addon.parent)
|
||||
addon_config[ATTR_TRANSLATIONS] = self._read_addon_translations(
|
||||
addon.parent
|
||||
)
|
||||
self.addons[addon_slug] = addon_config
|
||||
# store
|
||||
addon_config[ATTR_REPOSITORY] = repository
|
||||
addon_config[ATTR_LOCATON] = str(addon.parent)
|
||||
addon_config[ATTR_TRANSLATIONS] = _read_addon_translations(addon.parent)
|
||||
addons_config[addon_slug] = addon_config
|
||||
|
||||
def _set_builtin_repositories(self):
|
||||
return addons_config
|
||||
|
||||
self.addons.update(await self.sys_run_in_executor(_process_addons_config))
|
||||
|
||||
async def _set_builtin_repositories(self):
|
||||
"""Add local built-in repository into dataset."""
|
||||
try:
|
||||
builtin_file = Path(__file__).parent.joinpath("built-in.json")
|
||||
builtin_data = read_json_file(builtin_file)
|
||||
except ConfigurationFileError:
|
||||
_LOGGER.warning("Can't read built-in json")
|
||||
return
|
||||
|
||||
# core repository
|
||||
self.repositories[REPOSITORY_CORE] = builtin_data[REPOSITORY_CORE]
|
||||
|
||||
# local repository
|
||||
self.repositories[REPOSITORY_LOCAL] = builtin_data[REPOSITORY_LOCAL]
|
||||
|
||||
def _read_addon_translations(self, addon_path: Path) -> dict:
|
||||
"""Read translations from add-ons folder."""
|
||||
translations_dir = addon_path / "translations"
|
||||
translations = {}
|
||||
|
||||
if not translations_dir.exists():
|
||||
return translations
|
||||
|
||||
translation_files = [
|
||||
translation
|
||||
for translation in translations_dir.glob("*")
|
||||
if translation.suffix in FILE_SUFFIX_CONFIGURATION
|
||||
]
|
||||
|
||||
for translation in translation_files:
|
||||
def _get_builtins() -> dict[str, dict[str, str]] | None:
|
||||
try:
|
||||
translations[translation.stem] = SCHEMA_ADDON_TRANSLATIONS(
|
||||
read_json_or_yaml_file(translation)
|
||||
)
|
||||
builtin_file = Path(__file__).parent.joinpath("built-in.json")
|
||||
return read_json_file(builtin_file)
|
||||
except ConfigurationFileError:
|
||||
_LOGGER.warning("Can't read built-in json")
|
||||
return None
|
||||
|
||||
except (ConfigurationFileError, vol.Invalid) as err:
|
||||
_LOGGER.warning(
|
||||
"Can't read translations from %s - %s", translation, err
|
||||
)
|
||||
continue
|
||||
builtin_data = await self.sys_run_in_executor(_get_builtins)
|
||||
if builtin_data:
|
||||
# core repository
|
||||
self.repositories[REPOSITORY_CORE] = builtin_data[REPOSITORY_CORE]
|
||||
|
||||
return translations
|
||||
# local repository
|
||||
self.repositories[REPOSITORY_LOCAL] = builtin_data[REPOSITORY_LOCAL]
|
||||
|
@@ -77,6 +77,7 @@ class GitRepo(CoreSysAttributes):
|
||||
raise StoreGitError() from err
|
||||
|
||||
@Job(
|
||||
name="git_repo_clone",
|
||||
conditions=[JobCondition.FREE_SPACE, JobCondition.INTERNET_SYSTEM],
|
||||
on_condition=StoreJobError,
|
||||
)
|
||||
@@ -112,6 +113,7 @@ class GitRepo(CoreSysAttributes):
|
||||
raise StoreGitCloneError() from err
|
||||
|
||||
@Job(
|
||||
name="git_repo_pull",
|
||||
conditions=[JobCondition.FREE_SPACE, JobCondition.INTERNET_SYSTEM],
|
||||
on_condition=StoreJobError,
|
||||
)
|
||||
|
@@ -211,7 +211,11 @@ class Supervisor(CoreSysAttributes):
|
||||
|
||||
self.sys_create_task(self.sys_core.stop())
|
||||
|
||||
@Job(conditions=[JobCondition.RUNNING], on_condition=SupervisorJobError)
|
||||
@Job(
|
||||
name="supervisor_restart",
|
||||
conditions=[JobCondition.RUNNING],
|
||||
on_condition=SupervisorJobError,
|
||||
)
|
||||
async def restart(self) -> None:
|
||||
"""Restart Supervisor soft."""
|
||||
self.sys_core.exit_code = 100
|
||||
@@ -255,6 +259,7 @@ class Supervisor(CoreSysAttributes):
|
||||
_LOGGER.error("Repair of Supervisor failed")
|
||||
|
||||
@Job(
|
||||
name="supervisor_check_connectivity",
|
||||
limit=JobExecutionLimit.THROTTLE,
|
||||
throttle_period=_check_connectivity_throttle_period,
|
||||
)
|
||||
|
@@ -181,6 +181,7 @@ class Updater(FileConfiguration, CoreSysAttributes):
|
||||
self._data[ATTR_AUTO_UPDATE] = value
|
||||
|
||||
@Job(
|
||||
name="updater_fetch_data",
|
||||
conditions=[JobCondition.INTERNET_SYSTEM],
|
||||
on_condition=UpdaterJobError,
|
||||
limit=JobExecutionLimit.THROTTLE_WAIT,
|
||||
|
@@ -30,6 +30,8 @@ from .const import (
|
||||
ATTR_PWNED,
|
||||
ATTR_REGISTRIES,
|
||||
ATTR_SESSION,
|
||||
ATTR_SESSION_DATA,
|
||||
ATTR_SESSION_DATA_USER,
|
||||
ATTR_SUPERVISOR,
|
||||
ATTR_TIMEZONE,
|
||||
ATTR_USERNAME,
|
||||
@@ -49,7 +51,9 @@ RE_REGISTRY = re.compile(r"^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$")
|
||||
# pylint: disable=invalid-name
|
||||
network_port = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535))
|
||||
wait_boot = vol.All(vol.Coerce(int), vol.Range(min=1, max=60))
|
||||
docker_image = vol.Match(r"^([a-zA-Z\-\.:\d{}]+/)*?([\-\w{}]+)/([\-\w{}]+)$")
|
||||
docker_image = vol.Match(
|
||||
r"^([a-z0-9][a-z0-9.\-]*(:[0-9]+)?/)*?([a-z0-9{][a-z0-9.\-_{}]*/)*?([a-z0-9{][a-z0-9.\-_{}]*)$"
|
||||
)
|
||||
uuid_match = vol.Match(r"^[0-9a-f]{32}$")
|
||||
sha256 = vol.Match(r"^[0-9a-f]{64}$")
|
||||
token = vol.Match(r"^[0-9a-f]{32,256}$")
|
||||
@@ -176,18 +180,33 @@ SCHEMA_DOCKER_CONFIG = vol.Schema(
|
||||
|
||||
SCHEMA_AUTH_CONFIG = vol.Schema({sha256: sha256})
|
||||
|
||||
SCHEMA_SESSION_DATA = vol.Schema(
|
||||
{
|
||||
token: vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_SESSION_DATA_USER): vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("username"): str,
|
||||
vol.Required("displayname"): str,
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_INGRESS_CONFIG = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_SESSION, default=dict): vol.Schema(
|
||||
{token: vol.Coerce(float)}
|
||||
),
|
||||
vol.Required(ATTR_SESSION_DATA, default=dict): SCHEMA_SESSION_DATA,
|
||||
vol.Required(ATTR_PORTS, default=dict): vol.Schema({str: network_port}),
|
||||
},
|
||||
extra=vol.REMOVE_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable=no-value-for-parameter
|
||||
SCHEMA_SECURITY_CONFIG = vol.Schema(
|
||||
{
|
||||
|
@@ -18,6 +18,7 @@ from supervisor.docker.addon import DockerAddon
|
||||
from supervisor.docker.const import ContainerState
|
||||
from supervisor.docker.monitor import DockerContainerStateEvent
|
||||
from supervisor.exceptions import AddonsError, AddonsJobError, AudioUpdateError
|
||||
from supervisor.ingress import Ingress
|
||||
from supervisor.store.repository import Repository
|
||||
from supervisor.utils.dt import utcnow
|
||||
|
||||
@@ -523,18 +524,44 @@ async def test_restore(
|
||||
path_extern,
|
||||
) -> None:
|
||||
"""Test restoring an addon."""
|
||||
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
||||
install_addon_ssh.path_data.mkdir()
|
||||
await install_addon_ssh.load()
|
||||
|
||||
tarfile = SecureTarFile(get_fixture_path(f"backup_local_ssh_{status}.tar.gz"), "r")
|
||||
with patch.object(DockerAddon, "is_running", return_value=False), patch.object(
|
||||
CpuArch, "supported", new=PropertyMock(return_value=["aarch64"])
|
||||
):
|
||||
), patch.object(Ingress, "update_hass_panel") as update_hass_panel:
|
||||
start_task = await coresys.addons.restore(TEST_ADDON_SLUG, tarfile)
|
||||
|
||||
update_hass_panel.assert_called_once()
|
||||
|
||||
assert bool(start_task) is (status == "running")
|
||||
|
||||
|
||||
async def test_restore_while_running(
|
||||
coresys: CoreSys,
|
||||
install_addon_ssh: Addon,
|
||||
container: MagicMock,
|
||||
tmp_supervisor_data,
|
||||
path_extern,
|
||||
):
|
||||
"""Test restore of a running addon."""
|
||||
container.status = "running"
|
||||
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
||||
install_addon_ssh.path_data.mkdir()
|
||||
await install_addon_ssh.load()
|
||||
|
||||
tarfile = SecureTarFile(get_fixture_path("backup_local_ssh_stopped.tar.gz"), "r")
|
||||
with patch.object(DockerAddon, "is_running", return_value=True), patch.object(
|
||||
CpuArch, "supported", new=PropertyMock(return_value=["aarch64"])
|
||||
), patch.object(Ingress, "update_hass_panel"):
|
||||
start_task = await coresys.addons.restore(TEST_ADDON_SLUG, tarfile)
|
||||
|
||||
assert bool(start_task) is False
|
||||
container.stop.assert_called_once()
|
||||
|
||||
|
||||
async def test_start_when_running(
|
||||
coresys: CoreSys,
|
||||
install_addon_ssh: Addon,
|
||||
|
@@ -110,7 +110,7 @@ def test_invalid_repository():
|
||||
"""Validate basic config with invalid repositories."""
|
||||
config = load_json_fixture("basic-addon-config.json")
|
||||
|
||||
config["image"] = "something"
|
||||
config["image"] = "-invalid-something"
|
||||
with pytest.raises(vol.Invalid):
|
||||
vd.SCHEMA_ADDON_CONFIG(config)
|
||||
|
||||
|
@@ -1,6 +1,7 @@
|
||||
"""Test addon manager."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch
|
||||
|
||||
from awesomeversion import AwesomeVersion
|
||||
@@ -8,6 +9,7 @@ import pytest
|
||||
|
||||
from supervisor.addons.addon import Addon
|
||||
from supervisor.arch import CpuArch
|
||||
from supervisor.config import CoreConfig
|
||||
from supervisor.const import AddonBoot, AddonStartup, AddonState, BusEvent
|
||||
from supervisor.coresys import CoreSys
|
||||
from supervisor.docker.addon import DockerAddon
|
||||
@@ -22,6 +24,7 @@ from supervisor.exceptions import (
|
||||
)
|
||||
from supervisor.plugins.dns import PluginDns
|
||||
from supervisor.utils import check_exception_chain
|
||||
from supervisor.utils.common import write_json_file
|
||||
|
||||
from tests.common import load_json_fixture
|
||||
from tests.const import TEST_ADDON_SLUG
|
||||
@@ -57,7 +60,7 @@ async def test_image_added_removed_on_update(
|
||||
assert install_addon_ssh.image == "local/amd64-addon-ssh"
|
||||
assert coresys.addons.store.get(TEST_ADDON_SLUG).image == "test/amd64-my-ssh-addon"
|
||||
|
||||
with patch.object(DockerInterface, "_install") as install, patch.object(
|
||||
with patch.object(DockerInterface, "install") as install, patch.object(
|
||||
DockerAddon, "_build"
|
||||
) as build:
|
||||
await install_addon_ssh.update()
|
||||
@@ -77,7 +80,7 @@ async def test_image_added_removed_on_update(
|
||||
assert install_addon_ssh.image == "test/amd64-my-ssh-addon"
|
||||
assert coresys.addons.store.get(TEST_ADDON_SLUG).image == "local/amd64-addon-ssh"
|
||||
|
||||
with patch.object(DockerInterface, "_install") as install, patch.object(
|
||||
with patch.object(DockerInterface, "install") as install, patch.object(
|
||||
DockerAddon, "_build"
|
||||
) as build:
|
||||
await install_addon_ssh.update()
|
||||
@@ -249,7 +252,7 @@ async def test_update(
|
||||
|
||||
assert install_addon_ssh.need_update is True
|
||||
|
||||
with patch.object(DockerInterface, "_install"), patch.object(
|
||||
with patch.object(DockerInterface, "install"), patch.object(
|
||||
DockerAddon, "is_running", return_value=False
|
||||
):
|
||||
start_task = await coresys.addons.update(TEST_ADDON_SLUG)
|
||||
@@ -277,3 +280,87 @@ async def test_rebuild(
|
||||
start_task = await coresys.addons.rebuild(TEST_ADDON_SLUG)
|
||||
|
||||
assert bool(start_task) is (status == "running")
|
||||
|
||||
|
||||
async def test_start_wait_cancel_on_uninstall(
|
||||
coresys: CoreSys,
|
||||
install_addon_ssh: Addon,
|
||||
container: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
tmp_supervisor_data,
|
||||
path_extern,
|
||||
) -> None:
|
||||
"""Test the addon wait task is cancelled when addon is uninstalled."""
|
||||
install_addon_ssh.path_data.mkdir()
|
||||
container.attrs["Config"] = {"Healthcheck": "exists"}
|
||||
await install_addon_ssh.load()
|
||||
await asyncio.sleep(0)
|
||||
assert install_addon_ssh.state == AddonState.STOPPED
|
||||
|
||||
start_task = asyncio.create_task(await install_addon_ssh.start())
|
||||
assert start_task
|
||||
|
||||
coresys.bus.fire_event(
|
||||
BusEvent.DOCKER_CONTAINER_STATE_CHANGE,
|
||||
DockerContainerStateEvent(
|
||||
name=f"addon_{TEST_ADDON_SLUG}",
|
||||
state=ContainerState.RUNNING,
|
||||
id="abc123",
|
||||
time=1,
|
||||
),
|
||||
)
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
assert not start_task.done()
|
||||
assert install_addon_ssh.state == AddonState.STARTUP
|
||||
|
||||
caplog.clear()
|
||||
await coresys.addons.uninstall(TEST_ADDON_SLUG)
|
||||
await asyncio.sleep(0.01)
|
||||
assert start_task.done()
|
||||
assert "Wait for addon startup task cancelled" in caplog.text
|
||||
|
||||
|
||||
async def test_repository_file_missing(
|
||||
coresys: CoreSys, tmp_supervisor_data: Path, caplog: pytest.LogCaptureFixture
|
||||
):
|
||||
"""Test repository file is missing."""
|
||||
with patch.object(
|
||||
CoreConfig,
|
||||
"path_addons_git",
|
||||
new=PropertyMock(return_value=tmp_supervisor_data / "addons" / "git"),
|
||||
):
|
||||
repo_dir = coresys.config.path_addons_git / "test"
|
||||
repo_dir.mkdir(parents=True)
|
||||
|
||||
await coresys.store.data.update()
|
||||
|
||||
assert f"No repository information exists at {repo_dir.as_posix()}" in caplog.text
|
||||
|
||||
|
||||
async def test_repository_file_error(
|
||||
coresys: CoreSys, tmp_supervisor_data: Path, caplog: pytest.LogCaptureFixture
|
||||
):
|
||||
"""Test repository file is missing."""
|
||||
with patch.object(
|
||||
CoreConfig,
|
||||
"path_addons_git",
|
||||
new=PropertyMock(return_value=tmp_supervisor_data / "addons" / "git"),
|
||||
):
|
||||
repo_dir = coresys.config.path_addons_git / "test"
|
||||
repo_dir.mkdir(parents=True)
|
||||
|
||||
repo_file = repo_dir / "repository.json"
|
||||
|
||||
with repo_file.open("w") as file:
|
||||
file.write("not json")
|
||||
|
||||
await coresys.store.data.update()
|
||||
assert (
|
||||
f"Can't read repository information from {repo_file.as_posix()}"
|
||||
in caplog.text
|
||||
)
|
||||
|
||||
write_json_file(repo_file, {"invalid": "bad"})
|
||||
await coresys.store.data.update()
|
||||
assert f"Repository parse error {repo_dir.as_posix()}" in caplog.text
|
||||
|
@@ -171,6 +171,7 @@ async def test_api_addon_rebuild_healthcheck(
|
||||
path_extern,
|
||||
):
|
||||
"""Test rebuilding an addon waits for healthy."""
|
||||
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
||||
container.status = "running"
|
||||
install_addon_ssh.path_data.mkdir()
|
||||
container.attrs["Config"] = {"Healthcheck": "exists"}
|
||||
|
@@ -1,4 +1,5 @@
|
||||
"""Test ingress API."""
|
||||
# pylint: disable=protected-access
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -37,3 +38,50 @@ async def test_validate_session(api_client, coresys):
|
||||
assert await resp.json() == {"result": "ok", "data": {}}
|
||||
|
||||
assert coresys.ingress.sessions[session] > valid_time
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_session_with_user_id(api_client, coresys):
|
||||
"""Test validating ingress session with user ID passed."""
|
||||
with patch("aiohttp.web_request.BaseRequest.__getitem__", return_value=None):
|
||||
resp = await api_client.post(
|
||||
"/ingress/validate_session",
|
||||
json={"session": "non-existing"},
|
||||
)
|
||||
assert resp.status == 401
|
||||
|
||||
with patch(
|
||||
"aiohttp.web_request.BaseRequest.__getitem__",
|
||||
return_value=coresys.homeassistant,
|
||||
):
|
||||
client = coresys.homeassistant.websocket._client
|
||||
client.async_send_command.return_value = [
|
||||
{"id": "some-id", "name": "Some Name", "username": "sn"}
|
||||
]
|
||||
|
||||
resp = await api_client.post("/ingress/session", json={"user_id": "some-id"})
|
||||
result = await resp.json()
|
||||
|
||||
client.async_send_command.assert_called_with({"type": "config/auth/list"})
|
||||
|
||||
assert "session" in result["data"]
|
||||
session = result["data"]["session"]
|
||||
assert session in coresys.ingress.sessions
|
||||
|
||||
valid_time = coresys.ingress.sessions[session]
|
||||
|
||||
resp = await api_client.post(
|
||||
"/ingress/validate_session",
|
||||
json={"session": session},
|
||||
)
|
||||
assert resp.status == 200
|
||||
assert await resp.json() == {"result": "ok", "data": {}}
|
||||
|
||||
assert coresys.ingress.sessions[session] > valid_time
|
||||
|
||||
assert session in coresys.ingress.sessions_data
|
||||
assert coresys.ingress.get_session_data(session).user.id == "some-id"
|
||||
assert coresys.ingress.get_session_data(session).user.username == "sn"
|
||||
assert (
|
||||
coresys.ingress.get_session_data(session).user.display_name == "Some Name"
|
||||
)
|
||||
|
@@ -125,6 +125,7 @@ async def test_api_store_update_healthcheck(
|
||||
path_extern,
|
||||
):
|
||||
"""Test updating an addon with healthcheck waits for health status."""
|
||||
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
||||
container.status = "running"
|
||||
container.attrs["Config"] = {"Healthcheck": "exists"}
|
||||
install_addon_ssh.path_data.mkdir()
|
||||
@@ -176,7 +177,7 @@ async def test_api_store_update_healthcheck(
|
||||
asyncio.create_task(container_events())
|
||||
|
||||
with patch.object(DockerAddon, "run", new=container_events_task), patch.object(
|
||||
DockerInterface, "_install"
|
||||
DockerInterface, "install"
|
||||
), patch.object(DockerAddon, "is_running", return_value=False), patch.object(
|
||||
CpuArch, "supported", new=PropertyMock(return_value=["amd64"])
|
||||
):
|
||||
|
@@ -70,6 +70,13 @@ async def path_extern() -> None:
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def supervisor_name() -> None:
|
||||
"""Set env for supervisor name."""
|
||||
os.environ["SUPERVISOR_NAME"] = "hassio_supervisor"
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def docker() -> DockerAPI:
|
||||
"""Mock DockerAPI."""
|
||||
@@ -286,7 +293,13 @@ async def fixture_all_dbus_services(
|
||||
|
||||
@pytest.fixture
|
||||
async def coresys(
|
||||
event_loop, docker, dbus_session_bus, all_dbus_services, aiohttp_client, run_dir
|
||||
event_loop,
|
||||
docker,
|
||||
dbus_session_bus,
|
||||
all_dbus_services,
|
||||
aiohttp_client,
|
||||
run_dir,
|
||||
supervisor_name,
|
||||
) -> CoreSys:
|
||||
"""Create a CoreSys Mock."""
|
||||
with patch("supervisor.bootstrap.initialize_system"), patch(
|
||||
@@ -409,7 +422,9 @@ def sys_supervisor():
|
||||
|
||||
@pytest.fixture
|
||||
async def api_client(
|
||||
aiohttp_client, coresys: CoreSys, request: pytest.FixtureRequest
|
||||
aiohttp_client,
|
||||
coresys: CoreSys,
|
||||
request: pytest.FixtureRequest,
|
||||
) -> TestClient:
|
||||
"""Fixture for RestAPI client."""
|
||||
|
||||
@@ -428,9 +443,7 @@ async def api_client(
|
||||
api = RestAPI(coresys)
|
||||
api.webapp = web.Application(middlewares=[_security_middleware])
|
||||
api.start = AsyncMock()
|
||||
with patch("supervisor.docker.supervisor.os") as os:
|
||||
os.environ = {"SUPERVISOR_NAME": "hassio_supervisor"}
|
||||
await api.load()
|
||||
await api.load()
|
||||
yield await aiohttp_client(api.webapp)
|
||||
|
||||
|
||||
@@ -593,16 +606,12 @@ async def journald_logs(coresys: CoreSys) -> MagicMock:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def docker_logs(docker: DockerAPI) -> MagicMock:
|
||||
async def docker_logs(docker: DockerAPI, supervisor_name) -> MagicMock:
|
||||
"""Mock log output for a container from docker."""
|
||||
container_mock = MagicMock()
|
||||
container_mock.logs.return_value = load_binary_fixture("logs_docker_container.txt")
|
||||
docker.containers.get.return_value = container_mock
|
||||
|
||||
with patch("supervisor.docker.supervisor.os") as os:
|
||||
os.environ = {"SUPERVISOR_NAME": "hassio_supervisor"}
|
||||
|
||||
yield container_mock.logs
|
||||
yield container_mock.logs
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -640,7 +649,6 @@ async def os_available(request: pytest.FixtureRequest) -> None:
|
||||
@pytest.fixture
|
||||
async def mount_propagation(docker: DockerAPI, coresys: CoreSys) -> None:
|
||||
"""Mock supervisor connected to container with propagation set."""
|
||||
os.environ["SUPERVISOR_NAME"] = "hassio_supervisor"
|
||||
docker.containers.get.return_value = supervisor = MagicMock()
|
||||
supervisor.attrs = {
|
||||
"Mounts": [
|
||||
|
@@ -1,6 +1,9 @@
|
||||
"""Test settings generation from interface."""
|
||||
|
||||
from unittest.mock import PropertyMock, patch
|
||||
|
||||
from supervisor.dbus.network import NetworkManager
|
||||
from supervisor.dbus.network.interface import NetworkInterface
|
||||
from supervisor.dbus.network.setting.generate import get_connection_from_interface
|
||||
from supervisor.host.network import Interface
|
||||
|
||||
@@ -17,13 +20,24 @@ async def test_get_connection_from_interface(network_manager: NetworkManager):
|
||||
|
||||
assert "interface-name" not in connection_payload["connection"]
|
||||
assert connection_payload["connection"]["type"].value == "802-3-ethernet"
|
||||
assert (
|
||||
connection_payload["device"]["match-device"].value
|
||||
== "mac:AA:BB:CC:DD:EE:FF,interface-name:eth0"
|
||||
)
|
||||
assert connection_payload["match"]["path"].value == ["platform-ff3f0000.ethernet"]
|
||||
|
||||
assert connection_payload["ipv4"]["method"].value == "auto"
|
||||
assert "address-data" not in connection_payload["ipv4"]
|
||||
|
||||
assert connection_payload["ipv6"]["method"].value == "auto"
|
||||
assert "address-data" not in connection_payload["ipv6"]
|
||||
|
||||
|
||||
async def test_get_connection_no_path(network_manager: NetworkManager):
|
||||
"""Test network interface without a path."""
|
||||
dbus_interface = network_manager.get(TEST_INTERFACE)
|
||||
with patch.object(NetworkInterface, "path", new=PropertyMock(return_value=None)):
|
||||
interface = Interface.from_dbus_interface(dbus_interface)
|
||||
|
||||
connection_payload = get_connection_from_interface(interface)
|
||||
|
||||
assert "connection" in connection_payload
|
||||
assert "match" not in connection_payload
|
||||
|
||||
assert connection_payload["connection"]["interface-name"].value == "eth0"
|
||||
|
@@ -58,9 +58,7 @@ async def test_update(
|
||||
)
|
||||
assert settings["connection"]["autoconnect"] == Variant("b", True)
|
||||
|
||||
assert settings["device"] == {
|
||||
"match-device": Variant("s", "mac:AA:BB:CC:DD:EE:FF,interface-name:eth0")
|
||||
}
|
||||
assert settings["match"] == {"path": Variant("as", ["platform-ff3f0000.ethernet"])}
|
||||
|
||||
assert "ipv4" in settings
|
||||
assert settings["ipv4"]["method"] == Variant("s", "auto")
|
||||
|
@@ -77,9 +77,7 @@ SETINGS_FIXTURES: dict[str, dict[str, dict[str, Variant]]] = {
|
||||
"proxy": {},
|
||||
"802-3-ethernet": SETTINGS_FIXTURE["802-3-ethernet"],
|
||||
"802-11-wireless": SETTINGS_FIXTURE["802-11-wireless"],
|
||||
"device": {
|
||||
"match-device": Variant("s", "mac:AA:BB:CC:DD:EE:FF,interface-name:eth0"),
|
||||
},
|
||||
"match": {"path": Variant("as", ["platform-ff3f0000.ethernet"])},
|
||||
},
|
||||
}
|
||||
|
||||
|
@@ -194,7 +194,7 @@ async def test_addon_run_docker_error(
|
||||
coresys, addonsdata_system, "basic-addon-config.json"
|
||||
)
|
||||
|
||||
with patch.object(DockerAddon, "_stop"), patch.object(
|
||||
with patch.object(DockerAddon, "stop"), patch.object(
|
||||
AddonOptions, "validate", new=PropertyMock(return_value=lambda _: None)
|
||||
), pytest.raises(DockerNotFound):
|
||||
await docker_addon.run()
|
||||
@@ -218,7 +218,7 @@ async def test_addon_run_add_host_error(
|
||||
coresys, addonsdata_system, "basic-addon-config.json"
|
||||
)
|
||||
|
||||
with patch.object(DockerAddon, "_stop"), patch.object(
|
||||
with patch.object(DockerAddon, "stop"), patch.object(
|
||||
AddonOptions, "validate", new=PropertyMock(return_value=lambda _: None)
|
||||
), patch.object(PluginDns, "add_host", side_effect=(err := CoreDNSError())):
|
||||
await docker_addon.run()
|
||||
|
@@ -92,7 +92,7 @@ async def test_install_docker_error(
|
||||
):
|
||||
"""Test install fails due to docker error."""
|
||||
coresys.security.force = True
|
||||
with patch.object(HomeAssistantCore, "_start"), patch.object(
|
||||
with patch.object(HomeAssistantCore, "start"), patch.object(
|
||||
DockerHomeAssistant, "cleanup"
|
||||
), patch.object(
|
||||
Updater, "image_homeassistant", new=PropertyMock(return_value="homeassistant")
|
||||
@@ -119,7 +119,7 @@ async def test_install_other_error(
|
||||
"""Test install fails due to other error."""
|
||||
coresys.docker.images.pull.side_effect = [(err := OSError()), MagicMock()]
|
||||
|
||||
with patch.object(HomeAssistantCore, "_start"), patch.object(
|
||||
with patch.object(HomeAssistantCore, "start"), patch.object(
|
||||
DockerHomeAssistant, "cleanup"
|
||||
), patch.object(
|
||||
Updater, "image_homeassistant", new=PropertyMock(return_value="homeassistant")
|
||||
|
@@ -13,7 +13,6 @@ from supervisor.exceptions import HostNotSupportedError
|
||||
from supervisor.homeassistant.const import WSEvent, WSType
|
||||
from supervisor.host.const import WifiMode
|
||||
|
||||
from tests.common import mock_dbus_services
|
||||
from tests.dbus_service_mocks.base import DBusServiceMock
|
||||
from tests.dbus_service_mocks.network_active_connection import (
|
||||
ActiveConnection as ActiveConnectionService,
|
||||
@@ -253,47 +252,3 @@ async def test_host_connectivity_disabled(
|
||||
}
|
||||
)
|
||||
assert "connectivity_check" not in coresys.resolution.unsupported
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"interface_obj_path",
|
||||
[
|
||||
"/org/freedesktop/NetworkManager/Devices/4",
|
||||
"/org/freedesktop/NetworkManager/Devices/5",
|
||||
],
|
||||
)
|
||||
async def test_load_with_mac_or_name_change(
|
||||
coresys: CoreSys,
|
||||
network_manager_service: NetworkManagerService,
|
||||
interface_obj_path: str,
|
||||
):
|
||||
"""Test load fixes match-device settings if mac address or interface name has changed."""
|
||||
await mock_dbus_services(
|
||||
{
|
||||
"network_active_connection": "/org/freedesktop/NetworkManager/ActiveConnection/2",
|
||||
"network_connection_settings": "/org/freedesktop/NetworkManager/Settings/2",
|
||||
"network_device": interface_obj_path,
|
||||
},
|
||||
coresys.dbus.bus,
|
||||
)
|
||||
await coresys.dbus.network.update({"Devices": [interface_obj_path]})
|
||||
|
||||
network_manager_service.ActivateConnection.calls.clear()
|
||||
assert len(coresys.dbus.network.interfaces) == 1
|
||||
interface = next(iter(coresys.dbus.network.interfaces))
|
||||
assert interface.object_path == interface_obj_path
|
||||
expected_match_device = (
|
||||
f"mac:{interface.hw_address},interface-name:{interface.name}"
|
||||
)
|
||||
assert interface.settings.device.match_device != expected_match_device
|
||||
|
||||
await coresys.host.network.load()
|
||||
|
||||
assert network_manager_service.ActivateConnection.calls == [
|
||||
(
|
||||
"/org/freedesktop/NetworkManager/Settings/2",
|
||||
interface_obj_path,
|
||||
"/",
|
||||
)
|
||||
]
|
||||
assert interface.settings.device.match_device == expected_match_device
|
||||
|
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock, Mock, PropertyMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
from aiohttp.client_exceptions import ClientError
|
||||
import pytest
|
||||
@@ -18,8 +19,10 @@ from supervisor.exceptions import (
|
||||
)
|
||||
from supervisor.host.const import HostFeature
|
||||
from supervisor.host.manager import HostManager
|
||||
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
|
||||
from supervisor.plugins.audio import PluginAudio
|
||||
from supervisor.resolution.const import UnhealthyReason
|
||||
from supervisor.utils.dt import utcnow
|
||||
@@ -35,7 +38,7 @@ async def test_healthy(coresys: CoreSys, caplog: pytest.LogCaptureFixture):
|
||||
"""Initialize the test class."""
|
||||
self.coresys = coresys
|
||||
|
||||
@Job(conditions=[JobCondition.HEALTHY])
|
||||
@Job(name="test_healthy_execute", conditions=[JobCondition.HEALTHY])
|
||||
async def execute(self):
|
||||
"""Execute the class method."""
|
||||
return True
|
||||
@@ -77,12 +80,18 @@ async def test_internet(
|
||||
"""Initialize the test class."""
|
||||
self.coresys = coresys
|
||||
|
||||
@Job(conditions=[JobCondition.INTERNET_HOST])
|
||||
@Job(
|
||||
name=f"test_internet_execute_host_{uuid4().hex}",
|
||||
conditions=[JobCondition.INTERNET_HOST],
|
||||
)
|
||||
async def execute_host(self):
|
||||
"""Execute the class method."""
|
||||
return True
|
||||
|
||||
@Job(conditions=[JobCondition.INTERNET_SYSTEM])
|
||||
@Job(
|
||||
name=f"test_internet_execute_system_{uuid4().hex}",
|
||||
conditions=[JobCondition.INTERNET_SYSTEM],
|
||||
)
|
||||
async def execute_system(self):
|
||||
"""Execute the class method."""
|
||||
return True
|
||||
@@ -118,7 +127,7 @@ async def test_free_space(coresys: CoreSys):
|
||||
"""Initialize the test class."""
|
||||
self.coresys = coresys
|
||||
|
||||
@Job(conditions=[JobCondition.FREE_SPACE])
|
||||
@Job(name="test_free_space_execute", conditions=[JobCondition.FREE_SPACE])
|
||||
async def execute(self):
|
||||
"""Execute the class method."""
|
||||
return True
|
||||
@@ -144,7 +153,7 @@ async def test_haos(coresys: CoreSys):
|
||||
"""Initialize the test class."""
|
||||
self.coresys = coresys
|
||||
|
||||
@Job(conditions=[JobCondition.HAOS])
|
||||
@Job(name="test_haos_execute", conditions=[JobCondition.HAOS])
|
||||
async def execute(self):
|
||||
"""Execute the class method."""
|
||||
return True
|
||||
@@ -170,7 +179,7 @@ async def test_exception(coresys: CoreSys, capture_exception: Mock):
|
||||
"""Initialize the test class."""
|
||||
self.coresys = coresys
|
||||
|
||||
@Job(conditions=[JobCondition.HEALTHY])
|
||||
@Job(name="test_exception_execute", conditions=[JobCondition.HEALTHY])
|
||||
async def execute(self):
|
||||
"""Execute the class method."""
|
||||
raise HassioError()
|
||||
@@ -194,7 +203,9 @@ async def test_exception_not_handle(coresys: CoreSys, capture_exception: Mock):
|
||||
"""Initialize the test class."""
|
||||
self.coresys = coresys
|
||||
|
||||
@Job(conditions=[JobCondition.HEALTHY])
|
||||
@Job(
|
||||
name="test_exception_not_handle_execute", conditions=[JobCondition.HEALTHY]
|
||||
)
|
||||
async def execute(self):
|
||||
"""Execute the class method."""
|
||||
raise err
|
||||
@@ -217,7 +228,7 @@ async def test_running(coresys: CoreSys):
|
||||
"""Initialize the test class."""
|
||||
self.coresys = coresys
|
||||
|
||||
@Job(conditions=[JobCondition.RUNNING])
|
||||
@Job(name="test_running_execute", conditions=[JobCondition.RUNNING])
|
||||
async def execute(self):
|
||||
"""Execute the class method."""
|
||||
return True
|
||||
@@ -244,7 +255,11 @@ async def test_exception_conditions(coresys: CoreSys):
|
||||
"""Initialize the test class."""
|
||||
self.coresys = coresys
|
||||
|
||||
@Job(conditions=[JobCondition.RUNNING], on_condition=HassioError)
|
||||
@Job(
|
||||
name="test_exception_conditions_execute",
|
||||
conditions=[JobCondition.RUNNING],
|
||||
on_condition=HassioError,
|
||||
)
|
||||
async def execute(self):
|
||||
"""Execute the class method."""
|
||||
return True
|
||||
@@ -272,7 +287,10 @@ async def test_execution_limit_single_wait(
|
||||
self.coresys = coresys
|
||||
self.run = asyncio.Lock()
|
||||
|
||||
@Job(limit=JobExecutionLimit.SINGLE_WAIT)
|
||||
@Job(
|
||||
name="test_execution_limit_single_wait_execute",
|
||||
limit=JobExecutionLimit.SINGLE_WAIT,
|
||||
)
|
||||
async def execute(self, sleep: float):
|
||||
"""Execute the class method."""
|
||||
assert not self.run.locked()
|
||||
@@ -298,7 +316,11 @@ async def test_execution_limit_throttle_wait(
|
||||
self.run = asyncio.Lock()
|
||||
self.call = 0
|
||||
|
||||
@Job(limit=JobExecutionLimit.THROTTLE_WAIT, throttle_period=timedelta(hours=1))
|
||||
@Job(
|
||||
name="test_execution_limit_throttle_wait_execute",
|
||||
limit=JobExecutionLimit.THROTTLE_WAIT,
|
||||
throttle_period=timedelta(hours=1),
|
||||
)
|
||||
async def execute(self, sleep: float):
|
||||
"""Execute the class method."""
|
||||
assert not self.run.locked()
|
||||
@@ -331,6 +353,7 @@ async def test_execution_limit_throttle_rate_limit(
|
||||
self.call = 0
|
||||
|
||||
@Job(
|
||||
name=f"test_execution_limit_throttle_rate_limit_execute_{uuid4().hex}",
|
||||
limit=JobExecutionLimit.THROTTLE_RATE_LIMIT,
|
||||
throttle_period=timedelta(hours=1),
|
||||
throttle_max_calls=2,
|
||||
@@ -368,7 +391,11 @@ async def test_execution_limit_throttle(coresys: CoreSys, loop: asyncio.BaseEven
|
||||
self.run = asyncio.Lock()
|
||||
self.call = 0
|
||||
|
||||
@Job(limit=JobExecutionLimit.THROTTLE, throttle_period=timedelta(hours=1))
|
||||
@Job(
|
||||
name="test_execution_limit_throttle_execute",
|
||||
limit=JobExecutionLimit.THROTTLE,
|
||||
throttle_period=timedelta(hours=1),
|
||||
)
|
||||
async def execute(self, sleep: float):
|
||||
"""Execute the class method."""
|
||||
assert not self.run.locked()
|
||||
@@ -396,7 +423,11 @@ async def test_execution_limit_once(coresys: CoreSys, loop: asyncio.BaseEventLoo
|
||||
self.coresys = coresys
|
||||
self.run = asyncio.Lock()
|
||||
|
||||
@Job(limit=JobExecutionLimit.ONCE, on_condition=JobException)
|
||||
@Job(
|
||||
name="test_execution_limit_once_execute",
|
||||
limit=JobExecutionLimit.ONCE,
|
||||
on_condition=JobException,
|
||||
)
|
||||
async def execute(self, sleep: float):
|
||||
"""Execute the class method."""
|
||||
assert not self.run.locked()
|
||||
@@ -423,7 +454,10 @@ async def test_supervisor_updated(coresys: CoreSys):
|
||||
"""Initialize the test class."""
|
||||
self.coresys = coresys
|
||||
|
||||
@Job(conditions=[JobCondition.SUPERVISOR_UPDATED])
|
||||
@Job(
|
||||
name="test_supervisor_updated_execute",
|
||||
conditions=[JobCondition.SUPERVISOR_UPDATED],
|
||||
)
|
||||
async def execute(self) -> bool:
|
||||
"""Execute the class method."""
|
||||
return True
|
||||
@@ -451,7 +485,10 @@ async def test_plugins_updated(coresys: CoreSys):
|
||||
"""Initialize the test class."""
|
||||
self.coresys = coresys
|
||||
|
||||
@Job(conditions=[JobCondition.PLUGINS_UPDATED])
|
||||
@Job(
|
||||
name="test_plugins_updated_execute",
|
||||
conditions=[JobCondition.PLUGINS_UPDATED],
|
||||
)
|
||||
async def execute(self) -> bool:
|
||||
"""Execute the class method."""
|
||||
return True
|
||||
@@ -484,7 +521,7 @@ async def test_auto_update(coresys: CoreSys):
|
||||
"""Initialize the test class."""
|
||||
self.coresys = coresys
|
||||
|
||||
@Job(conditions=[JobCondition.AUTO_UPDATE])
|
||||
@Job(name="test_auto_update_execute", conditions=[JobCondition.AUTO_UPDATE])
|
||||
async def execute(self) -> bool:
|
||||
"""Execute the class method."""
|
||||
return True
|
||||
@@ -510,7 +547,7 @@ async def test_os_agent(coresys: CoreSys):
|
||||
"""Initialize the test class."""
|
||||
self.coresys = coresys
|
||||
|
||||
@Job(conditions=[JobCondition.OS_AGENT])
|
||||
@Job(name="test_os_agent_execute", conditions=[JobCondition.OS_AGENT])
|
||||
async def execute(self) -> bool:
|
||||
"""Execute the class method."""
|
||||
return True
|
||||
@@ -539,7 +576,7 @@ async def test_host_network(coresys: CoreSys):
|
||||
"""Initialize the test class."""
|
||||
self.coresys = coresys
|
||||
|
||||
@Job(conditions=[JobCondition.HOST_NETWORK])
|
||||
@Job(name="test_host_network_execute", conditions=[JobCondition.HOST_NETWORK])
|
||||
async def execute(self) -> bool:
|
||||
"""Execute the class method."""
|
||||
return True
|
||||
@@ -552,3 +589,354 @@ async def test_host_network(coresys: CoreSys):
|
||||
|
||||
coresys.jobs.ignore_conditions = [JobCondition.HOST_NETWORK]
|
||||
assert await test.execute()
|
||||
|
||||
|
||||
async def test_job_group_once(coresys: CoreSys, loop: asyncio.BaseEventLoop):
|
||||
"""Test job group once execution limitation."""
|
||||
|
||||
class TestClass(JobGroup):
|
||||
"""Test class."""
|
||||
|
||||
def __init__(self, coresys: CoreSys):
|
||||
"""Initialize the test class."""
|
||||
super().__init__(coresys, "TestClass")
|
||||
self.event = asyncio.Event()
|
||||
|
||||
@Job(
|
||||
name="test_job_group_once_inner_execute",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=JobException,
|
||||
)
|
||||
async def inner_execute(self) -> bool:
|
||||
"""Inner class method called by execute, group level lock allows this."""
|
||||
await self.event.wait()
|
||||
return True
|
||||
|
||||
@Job(
|
||||
name="test_job_group_once_execute",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=JobException,
|
||||
)
|
||||
async def execute(self) -> bool:
|
||||
"""Execute the class method."""
|
||||
return await self.inner_execute()
|
||||
|
||||
@Job(
|
||||
name="test_job_group_once_separate_execute",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=JobException,
|
||||
)
|
||||
async def separate_execute(self) -> bool:
|
||||
"""Alternate execute method that shares group lock."""
|
||||
return True
|
||||
|
||||
@Job(
|
||||
name="test_job_group_once_unrelated",
|
||||
limit=JobExecutionLimit.ONCE,
|
||||
on_condition=JobException,
|
||||
)
|
||||
async def unrelated_method(self) -> bool:
|
||||
"""Unrelated method, sparate job with separate lock."""
|
||||
return True
|
||||
|
||||
test = TestClass(coresys)
|
||||
run_task = loop.create_task(test.execute())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# All methods with group limits should be locked
|
||||
with pytest.raises(JobException):
|
||||
await test.execute()
|
||||
|
||||
with pytest.raises(JobException):
|
||||
await test.inner_execute()
|
||||
|
||||
with pytest.raises(JobException):
|
||||
await test.separate_execute()
|
||||
|
||||
# The once method is still callable
|
||||
assert await test.unrelated_method()
|
||||
|
||||
test.event.set()
|
||||
assert await run_task
|
||||
|
||||
|
||||
async def test_job_group_wait(coresys: CoreSys, loop: asyncio.BaseEventLoop):
|
||||
"""Test job group wait execution limitation."""
|
||||
|
||||
class TestClass(JobGroup):
|
||||
"""Test class."""
|
||||
|
||||
def __init__(self, coresys: CoreSys):
|
||||
"""Initialize the test class."""
|
||||
super().__init__(coresys, "TestClass")
|
||||
self.execute_count = 0
|
||||
self.other_count = 0
|
||||
self.event = asyncio.Event()
|
||||
|
||||
@Job(
|
||||
name="test_job_group_wait_inner_execute",
|
||||
limit=JobExecutionLimit.GROUP_WAIT,
|
||||
on_condition=JobException,
|
||||
)
|
||||
async def inner_execute(self) -> None:
|
||||
"""Inner class method called by execute, group level lock allows this."""
|
||||
self.execute_count += 1
|
||||
await self.event.wait()
|
||||
|
||||
@Job(
|
||||
name="test_job_group_wait_execute",
|
||||
limit=JobExecutionLimit.GROUP_WAIT,
|
||||
on_condition=JobException,
|
||||
)
|
||||
async def execute(self) -> None:
|
||||
"""Execute the class method."""
|
||||
await self.inner_execute()
|
||||
|
||||
@Job(
|
||||
name="test_job_group_wait_separate_execute",
|
||||
limit=JobExecutionLimit.GROUP_WAIT,
|
||||
on_condition=JobException,
|
||||
)
|
||||
async def separate_execute(self) -> None:
|
||||
"""Alternate execute method that shares group lock."""
|
||||
self.other_count += 1
|
||||
|
||||
test = TestClass(coresys)
|
||||
run_task = loop.create_task(test.execute())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
repeat_task = loop.create_task(test.execute())
|
||||
other_task = loop.create_task(test.separate_execute())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert test.execute_count == 1
|
||||
assert test.other_count == 0
|
||||
|
||||
test.event.set()
|
||||
await run_task
|
||||
await repeat_task
|
||||
await other_task
|
||||
|
||||
assert test.execute_count == 2
|
||||
assert test.other_count == 1
|
||||
|
||||
|
||||
async def test_job_cleanup(coresys: CoreSys, loop: asyncio.BaseEventLoop):
|
||||
"""Test job is cleaned up."""
|
||||
|
||||
class TestClass:
|
||||
"""Test class."""
|
||||
|
||||
def __init__(self, coresys: CoreSys):
|
||||
"""Initialize the test class."""
|
||||
self.coresys = coresys
|
||||
self.event = asyncio.Event()
|
||||
self.job: SupervisorJob | None = None
|
||||
|
||||
@Job(name="test_job_cleanup_execute", limit=JobExecutionLimit.ONCE)
|
||||
async def execute(self):
|
||||
"""Execute the class method."""
|
||||
self.job = coresys.jobs.get_job()
|
||||
await self.event.wait()
|
||||
return True
|
||||
|
||||
test = TestClass(coresys)
|
||||
run_task = loop.create_task(test.execute())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert coresys.jobs.jobs == [test.job]
|
||||
assert not test.job.done
|
||||
|
||||
test.event.set()
|
||||
assert await run_task
|
||||
|
||||
assert coresys.jobs.jobs == []
|
||||
assert test.job.done
|
||||
|
||||
|
||||
async def test_job_skip_cleanup(coresys: CoreSys, loop: asyncio.BaseEventLoop):
|
||||
"""Test job is left in job manager when cleanup is false."""
|
||||
|
||||
class TestClass:
|
||||
"""Test class."""
|
||||
|
||||
def __init__(self, coresys: CoreSys):
|
||||
"""Initialize the test class."""
|
||||
self.coresys = coresys
|
||||
self.event = asyncio.Event()
|
||||
self.job: SupervisorJob | None = None
|
||||
|
||||
@Job(
|
||||
name="test_job_skip_cleanup_execute",
|
||||
limit=JobExecutionLimit.ONCE,
|
||||
cleanup=False,
|
||||
)
|
||||
async def execute(self):
|
||||
"""Execute the class method."""
|
||||
self.job = coresys.jobs.get_job()
|
||||
await self.event.wait()
|
||||
return True
|
||||
|
||||
test = TestClass(coresys)
|
||||
run_task = loop.create_task(test.execute())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert coresys.jobs.jobs == [test.job]
|
||||
assert not test.job.done
|
||||
|
||||
test.event.set()
|
||||
assert await run_task
|
||||
|
||||
assert coresys.jobs.jobs == [test.job]
|
||||
assert test.job.done
|
||||
|
||||
|
||||
async def test_execution_limit_group_throttle(
|
||||
coresys: CoreSys, loop: asyncio.BaseEventLoop
|
||||
):
|
||||
"""Test the group throttle execution limit."""
|
||||
|
||||
class TestClass(JobGroup):
|
||||
"""Test class."""
|
||||
|
||||
def __init__(self, coresys: CoreSys, reference: str):
|
||||
"""Initialize the test class."""
|
||||
super().__init__(coresys, f"test_class_{reference}", reference)
|
||||
self.run = asyncio.Lock()
|
||||
self.call = 0
|
||||
|
||||
@Job(
|
||||
name="test_execution_limit_group_throttle_execute",
|
||||
limit=JobExecutionLimit.GROUP_THROTTLE,
|
||||
throttle_period=timedelta(milliseconds=95),
|
||||
)
|
||||
async def execute(self, sleep: float):
|
||||
"""Execute the class method."""
|
||||
assert not self.run.locked()
|
||||
async with self.run:
|
||||
await asyncio.sleep(sleep)
|
||||
self.call += 1
|
||||
|
||||
test1 = TestClass(coresys, "test1")
|
||||
test2 = TestClass(coresys, "test2")
|
||||
|
||||
# One call of each should work. The subsequent calls will be silently throttled due to period
|
||||
await asyncio.gather(
|
||||
test1.execute(0), test1.execute(0), test2.execute(0), test2.execute(0)
|
||||
)
|
||||
assert test1.call == 1
|
||||
assert test2.call == 1
|
||||
|
||||
# First call to each will work again since period cleared. Second throttled once more as they don't wait
|
||||
with time_machine.travel(utcnow() + timedelta(milliseconds=100)):
|
||||
await asyncio.gather(
|
||||
test1.execute(0.1),
|
||||
test1.execute(0.1),
|
||||
test2.execute(0.1),
|
||||
test2.execute(0.1),
|
||||
)
|
||||
|
||||
assert test1.call == 2
|
||||
assert test2.call == 2
|
||||
|
||||
|
||||
async def test_execution_limit_group_throttle_wait(
|
||||
coresys: CoreSys, loop: asyncio.BaseEventLoop
|
||||
):
|
||||
"""Test the group throttle wait job execution limit."""
|
||||
|
||||
class TestClass(JobGroup):
|
||||
"""Test class."""
|
||||
|
||||
def __init__(self, coresys: CoreSys, reference: str):
|
||||
"""Initialize the test class."""
|
||||
super().__init__(coresys, f"test_class_{reference}", reference)
|
||||
self.run = asyncio.Lock()
|
||||
self.call = 0
|
||||
|
||||
@Job(
|
||||
name="test_execution_limit_group_throttle_wait_execute",
|
||||
limit=JobExecutionLimit.GROUP_THROTTLE_WAIT,
|
||||
throttle_period=timedelta(milliseconds=95),
|
||||
)
|
||||
async def execute(self, sleep: float):
|
||||
"""Execute the class method."""
|
||||
assert not self.run.locked()
|
||||
async with self.run:
|
||||
await asyncio.sleep(sleep)
|
||||
self.call += 1
|
||||
|
||||
test1 = TestClass(coresys, "test1")
|
||||
test2 = TestClass(coresys, "test2")
|
||||
|
||||
# One call of each should work. The subsequent calls will be silently throttled after waiting due to period
|
||||
await asyncio.gather(
|
||||
*[test1.execute(0), test1.execute(0), test2.execute(0), test2.execute(0)]
|
||||
)
|
||||
assert test1.call == 1
|
||||
assert test2.call == 1
|
||||
|
||||
# All calls should work as we cleared the period. And tasks take longer then period and are queued
|
||||
with time_machine.travel(utcnow() + timedelta(milliseconds=100)):
|
||||
await asyncio.gather(
|
||||
*[
|
||||
test1.execute(0.1),
|
||||
test1.execute(0.1),
|
||||
test2.execute(0.1),
|
||||
test2.execute(0.1),
|
||||
]
|
||||
)
|
||||
|
||||
assert test1.call == 3
|
||||
assert test2.call == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize("error", [None, PluginJobError])
|
||||
async def test_execution_limit_group_throttle_rate_limit(
|
||||
coresys: CoreSys, loop: asyncio.BaseEventLoop, error: JobException | None
|
||||
):
|
||||
"""Test the group throttle rate limit job execution limit."""
|
||||
|
||||
class TestClass(JobGroup):
|
||||
"""Test class."""
|
||||
|
||||
def __init__(self, coresys: CoreSys, reference: str):
|
||||
"""Initialize the test class."""
|
||||
super().__init__(coresys, f"test_class_{reference}", reference)
|
||||
self.run = asyncio.Lock()
|
||||
self.call = 0
|
||||
|
||||
@Job(
|
||||
name=f"test_execution_limit_group_throttle_rate_limit_execute_{uuid4().hex}",
|
||||
limit=JobExecutionLimit.GROUP_THROTTLE_RATE_LIMIT,
|
||||
throttle_period=timedelta(hours=1),
|
||||
throttle_max_calls=2,
|
||||
on_condition=error,
|
||||
)
|
||||
async def execute(self):
|
||||
"""Execute the class method."""
|
||||
self.call += 1
|
||||
|
||||
test1 = TestClass(coresys, "test1")
|
||||
test2 = TestClass(coresys, "test2")
|
||||
|
||||
await asyncio.gather(
|
||||
*[test1.execute(), test1.execute(), test2.execute(), test2.execute()]
|
||||
)
|
||||
assert test1.call == 2
|
||||
assert test2.call == 2
|
||||
|
||||
with pytest.raises(JobException if error is None else error):
|
||||
await test1.execute()
|
||||
with pytest.raises(JobException if error is None else error):
|
||||
await test2.execute()
|
||||
|
||||
assert test1.call == 2
|
||||
assert test2.call == 2
|
||||
|
||||
with time_machine.travel(utcnow() + timedelta(hours=1)):
|
||||
await test1.execute()
|
||||
await test2.execute()
|
||||
|
||||
assert test1.call == 3
|
||||
assert test2.call == 3
|
||||
|
@@ -1,39 +1,76 @@
|
||||
"""Test the condition decorators."""
|
||||
|
||||
import pytest
|
||||
|
||||
# pylint: disable=protected-access,import-error
|
||||
from supervisor.coresys import CoreSys
|
||||
from supervisor.exceptions import JobStartException
|
||||
|
||||
TEST_JOB = "test"
|
||||
|
||||
|
||||
async def test_add_job(coresys: CoreSys):
|
||||
"""Test adding jobs."""
|
||||
job = coresys.jobs.get_job(TEST_JOB)
|
||||
job = coresys.jobs.new_job(TEST_JOB)
|
||||
|
||||
assert job.name in coresys.jobs.jobs
|
||||
assert job in coresys.jobs.jobs
|
||||
|
||||
|
||||
async def test_remove_job_directly(coresys: CoreSys):
|
||||
async def test_remove_job_directly(coresys: CoreSys, caplog: pytest.LogCaptureFixture):
|
||||
"""Test removing jobs from manager."""
|
||||
job = coresys.jobs.get_job(TEST_JOB)
|
||||
job = coresys.jobs.new_job(TEST_JOB)
|
||||
|
||||
assert job in coresys.jobs.jobs
|
||||
|
||||
assert job.name in coresys.jobs.jobs
|
||||
coresys.jobs.remove_job(job)
|
||||
assert job.name not in coresys.jobs.jobs
|
||||
assert job not in coresys.jobs.jobs
|
||||
assert f"Removing incomplete job {job.name}" in caplog.text
|
||||
|
||||
|
||||
async def test_remove_job_with_progress(coresys: CoreSys):
|
||||
"""Test removing jobs by setting progress to 100."""
|
||||
job = coresys.jobs.get_job(TEST_JOB)
|
||||
async def test_job_done(coresys: CoreSys):
|
||||
"""Test done set correctly with jobs."""
|
||||
job = coresys.jobs.new_job(TEST_JOB)
|
||||
assert not job.done
|
||||
assert coresys.jobs.get_job() != job
|
||||
|
||||
assert job.name in coresys.jobs.jobs
|
||||
job.update(progress=100)
|
||||
assert job.name not in coresys.jobs.jobs
|
||||
with job.start():
|
||||
assert coresys.jobs.get_job() == job
|
||||
assert not job.done
|
||||
|
||||
assert coresys.jobs.get_job() != job
|
||||
assert job.done
|
||||
|
||||
with pytest.raises(JobStartException):
|
||||
with job.start():
|
||||
pass
|
||||
|
||||
|
||||
async def test_job_start_bad_parent(coresys: CoreSys):
|
||||
"""Test job cannot be started outside of parent."""
|
||||
job = coresys.jobs.new_job(TEST_JOB)
|
||||
job2 = coresys.jobs.new_job(f"{TEST_JOB}_2")
|
||||
|
||||
with job.start():
|
||||
with pytest.raises(JobStartException):
|
||||
with job2.start():
|
||||
pass
|
||||
|
||||
with job2.start():
|
||||
assert coresys.jobs.get_job() == job2
|
||||
|
||||
|
||||
async def test_update_job(coresys: CoreSys):
|
||||
"""Test updating jobs."""
|
||||
job = coresys.jobs.get_job(TEST_JOB)
|
||||
job = coresys.jobs.new_job(TEST_JOB)
|
||||
|
||||
job.update(progress=50, stage="stage")
|
||||
job.progress = 50
|
||||
assert job.progress == 50
|
||||
|
||||
job.stage = "stage"
|
||||
assert job.stage == "stage"
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
job.progress = 110
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
job.progress = -10
|
||||
|
@@ -6,8 +6,16 @@ import pytest
|
||||
|
||||
from supervisor.const import LogLevel
|
||||
from supervisor.coresys import CoreSys
|
||||
from supervisor.docker.audio import DockerAudio
|
||||
|
||||
from tests.plugins.test_dns import fixture_docker_interface # noqa: F401
|
||||
|
||||
@pytest.fixture(name="docker_interface")
|
||||
async def fixture_docker_interface() -> tuple[AsyncMock, AsyncMock]:
|
||||
"""Mock docker interface methods."""
|
||||
with patch.object(DockerAudio, "run") as run, patch.object(
|
||||
DockerAudio, "restart"
|
||||
) as restart:
|
||||
yield (run, restart)
|
||||
|
||||
|
||||
@pytest.fixture(name="write_json")
|
||||
|
@@ -9,7 +9,7 @@ import pytest
|
||||
from supervisor.const import BusEvent, LogLevel
|
||||
from supervisor.coresys import CoreSys
|
||||
from supervisor.docker.const import ContainerState
|
||||
from supervisor.docker.interface import DockerInterface
|
||||
from supervisor.docker.dns import DockerDNS
|
||||
from supervisor.docker.monitor import DockerContainerStateEvent
|
||||
from supervisor.plugins.dns import HostEntry
|
||||
from supervisor.resolution.const import ContextType, IssueType, SuggestionType
|
||||
@@ -19,8 +19,8 @@ from supervisor.resolution.data import Issue, Suggestion
|
||||
@pytest.fixture(name="docker_interface")
|
||||
async def fixture_docker_interface() -> tuple[AsyncMock, AsyncMock]:
|
||||
"""Mock docker interface methods."""
|
||||
with patch.object(DockerInterface, "run") as run, patch.object(
|
||||
DockerInterface, "restart"
|
||||
with patch.object(DockerDNS, "run") as run, patch.object(
|
||||
DockerDNS, "restart"
|
||||
) as restart:
|
||||
yield (run, restart)
|
||||
|
||||
|
@@ -5,7 +5,7 @@ from unittest.mock import patch
|
||||
from supervisor.coresys import CoreSys
|
||||
|
||||
|
||||
def test_read_addon_files(coresys: CoreSys):
|
||||
async def test_read_addon_files(coresys: CoreSys):
|
||||
"""Test that we are reading add-on files correctly."""
|
||||
with patch(
|
||||
"pathlib.Path.glob",
|
||||
@@ -19,7 +19,7 @@ def test_read_addon_files(coresys: CoreSys):
|
||||
Path(".circleci/config.yml"),
|
||||
],
|
||||
):
|
||||
addon_list = coresys.store.data._find_addons(Path("test"), {})
|
||||
addon_list = await coresys.store.data._find_addons(Path("test"), {})
|
||||
|
||||
assert len(addon_list) == 1
|
||||
assert str(addon_list[0]) == "addon/config.yml"
|
||||
|
@@ -1,16 +1,20 @@
|
||||
"""Test loading add-translation."""
|
||||
# pylint: disable=import-error,protected-access
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from supervisor.coresys import CoreSys
|
||||
from supervisor.store.data import _read_addon_translations
|
||||
from supervisor.utils.common import write_json_or_yaml_file
|
||||
|
||||
|
||||
def test_loading_traslations(coresys: CoreSys, tmp_path):
|
||||
def test_loading_traslations(coresys: CoreSys, tmp_path: Path):
|
||||
"""Test loading add-translation."""
|
||||
os.makedirs(tmp_path / "translations")
|
||||
# no transaltions
|
||||
assert coresys.store.data._read_addon_translations(tmp_path) == {}
|
||||
assert _read_addon_translations(tmp_path) == {}
|
||||
|
||||
for file in ("en.json", "es.json"):
|
||||
write_json_or_yaml_file(
|
||||
@@ -27,7 +31,7 @@ def test_loading_traslations(coresys: CoreSys, tmp_path):
|
||||
},
|
||||
)
|
||||
|
||||
translations = coresys.store.data._read_addon_translations(tmp_path)
|
||||
translations = _read_addon_translations(tmp_path)
|
||||
|
||||
assert translations["en"]["configuration"]["test"]["name"] == "test"
|
||||
assert translations["es"]["configuration"]["test"]["name"] == "test"
|
||||
@@ -37,3 +41,22 @@ def test_loading_traslations(coresys: CoreSys, tmp_path):
|
||||
assert "test" not in translations["en"]["configuration"]["test"]
|
||||
|
||||
assert translations["no"]["network"]["80/tcp"] == "Webserver port"
|
||||
|
||||
|
||||
def test_translation_file_failure(
|
||||
coresys: CoreSys, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
):
|
||||
"""Test translations load if one fails."""
|
||||
os.makedirs(tmp_path / "translations")
|
||||
write_json_or_yaml_file(
|
||||
tmp_path / "translations" / "en.json",
|
||||
{"configuration": {"test": {"name": "test", "test": "test"}}},
|
||||
)
|
||||
fail_path = tmp_path / "translations" / "de.json"
|
||||
with fail_path.open("w") as de_file:
|
||||
de_file.write("not json")
|
||||
|
||||
translations = _read_addon_translations(tmp_path)
|
||||
|
||||
assert translations["en"]["configuration"]["test"]["name"] == "test"
|
||||
assert f"Can't read translations from {fail_path.as_posix()}" in caplog.text
|
||||
|
@@ -103,6 +103,26 @@ async def test_raspberrypi4_64_arch(coresys, sys_machine, sys_supervisor):
|
||||
assert coresys.arch.supported == ["aarch64", "armv7", "armhf"]
|
||||
|
||||
|
||||
async def test_yellow_arch(coresys, sys_machine, sys_supervisor):
|
||||
"""Test arch for yellow."""
|
||||
sys_machine.return_value = "yellow"
|
||||
sys_supervisor.arch = "aarch64"
|
||||
await coresys.arch.load()
|
||||
|
||||
assert coresys.arch.default == "aarch64"
|
||||
assert coresys.arch.supported == ["aarch64", "armv7", "armhf"]
|
||||
|
||||
|
||||
async def test_green_arch(coresys, sys_machine, sys_supervisor):
|
||||
"""Test arch for green."""
|
||||
sys_machine.return_value = "green"
|
||||
sys_supervisor.arch = "aarch64"
|
||||
await coresys.arch.load()
|
||||
|
||||
assert coresys.arch.default == "aarch64"
|
||||
assert coresys.arch.supported == ["aarch64", "armv7", "armhf"]
|
||||
|
||||
|
||||
async def test_tinker_arch(coresys, sys_machine, sys_supervisor):
|
||||
"""Test arch for tinker."""
|
||||
sys_machine.return_value = "tinker"
|
||||
|
@@ -1,6 +1,7 @@
|
||||
"""Test ingress."""
|
||||
from datetime import timedelta
|
||||
|
||||
from supervisor.const import ATTR_SESSION_DATA_USER_ID
|
||||
from supervisor.utils.dt import utc_from_timestamp
|
||||
|
||||
|
||||
@@ -20,6 +21,21 @@ def test_session_handling(coresys):
|
||||
assert not coresys.ingress.validate_session(session)
|
||||
assert not coresys.ingress.validate_session("invalid session")
|
||||
|
||||
session_data = coresys.ingress.get_session_data(session)
|
||||
assert session_data is None
|
||||
|
||||
|
||||
def test_session_handling_with_session_data(coresys):
|
||||
"""Create and test session."""
|
||||
session = coresys.ingress.create_session(
|
||||
dict([(ATTR_SESSION_DATA_USER_ID, "some-id")])
|
||||
)
|
||||
|
||||
assert session
|
||||
|
||||
session_data = coresys.ingress.get_session_data(session)
|
||||
assert session_data[ATTR_SESSION_DATA_USER_ID] == "some-id"
|
||||
|
||||
|
||||
async def test_save_on_unload(coresys):
|
||||
"""Test called save on unload."""
|
||||
|
@@ -16,6 +16,24 @@ DNS_GOOD_V6 = [
|
||||
"DNS://2606:4700:4700::1001", # cloudflare
|
||||
]
|
||||
DNS_BAD = ["hello world", "https://foo.bar", "", "dns://example.com"]
|
||||
IMAGE_NAME_GOOD = [
|
||||
"ghcr.io/home-assistant/{machine}-homeassistant",
|
||||
"ghcr.io/home-assistant/{arch}-homeassistant",
|
||||
"homeassistant/{arch}-homeassistant",
|
||||
"doocker.io/homeassistant/{arch}-homeassistant",
|
||||
"ghcr.io/home-assistant/amd64-homeassistant",
|
||||
"homeassistant/amd64-homeassistant",
|
||||
"ttl.sh/homeassistant",
|
||||
"myreg.local:8080/homeassistant",
|
||||
]
|
||||
IMAGE_NAME_BAD = [
|
||||
"ghcr.io/home-assistant/homeassistant:123",
|
||||
".ghcr.io/home-assistant/homeassistant",
|
||||
"HOMEASSISTANT/homeassistant",
|
||||
"homeassistant/HOMEASSISTANT",
|
||||
"homeassistant/_homeassistant",
|
||||
"homeassistant/-homeassistant",
|
||||
]
|
||||
|
||||
|
||||
async def test_dns_url_v4_good():
|
||||
@@ -72,6 +90,19 @@ def test_dns_server_list_bad_combined():
|
||||
assert validate.dns_server_list(combined)
|
||||
|
||||
|
||||
def test_image_name_good():
|
||||
"""Test container image names validator with known-good image names."""
|
||||
for image_name in IMAGE_NAME_GOOD:
|
||||
assert validate.docker_image(image_name)
|
||||
|
||||
|
||||
def test_image_name_bad():
|
||||
"""Test container image names validator with known-bad image names."""
|
||||
for image_name in IMAGE_NAME_BAD:
|
||||
with pytest.raises(vol.error.Invalid):
|
||||
assert validate.docker_image(image_name)
|
||||
|
||||
|
||||
def test_version_complex():
|
||||
"""Test version simple with good version."""
|
||||
for version in (
|
||||
|
Reference in New Issue
Block a user