mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-08-14 19:49:21 +00:00
Compare commits
2 Commits
restart-ad
...
handle-git
Author | SHA1 | Date | |
---|---|---|---|
![]() |
1f7aafcfd7 | ||
![]() |
ed45651fd9 |
30
.github/workflows/ci.yaml
vendored
30
.github/workflows/ci.yaml
vendored
@@ -34,7 +34,7 @@ jobs:
|
|||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
- name: Restore Python virtual environment
|
- name: Restore Python virtual environment
|
||||||
id: cache-venv
|
id: cache-venv
|
||||||
uses: actions/cache@v4.2.4
|
uses: actions/cache@v4.2.3
|
||||||
with:
|
with:
|
||||||
path: venv
|
path: venv
|
||||||
key: |
|
key: |
|
||||||
@@ -48,7 +48,7 @@ jobs:
|
|||||||
pip install -r requirements.txt -r requirements_tests.txt
|
pip install -r requirements.txt -r requirements_tests.txt
|
||||||
- name: Restore pre-commit environment from cache
|
- name: Restore pre-commit environment from cache
|
||||||
id: cache-precommit
|
id: cache-precommit
|
||||||
uses: actions/cache@v4.2.4
|
uses: actions/cache@v4.2.3
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||||
lookup-only: true
|
lookup-only: true
|
||||||
@@ -76,7 +76,7 @@ jobs:
|
|||||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||||
- name: Restore Python virtual environment
|
- name: Restore Python virtual environment
|
||||||
id: cache-venv
|
id: cache-venv
|
||||||
uses: actions/cache@v4.2.4
|
uses: actions/cache@v4.2.3
|
||||||
with:
|
with:
|
||||||
path: venv
|
path: venv
|
||||||
key: |
|
key: |
|
||||||
@@ -88,7 +88,7 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
- name: Restore pre-commit environment from cache
|
- name: Restore pre-commit environment from cache
|
||||||
id: cache-precommit
|
id: cache-precommit
|
||||||
uses: actions/cache@v4.2.4
|
uses: actions/cache@v4.2.3
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||||
key: |
|
key: |
|
||||||
@@ -119,7 +119,7 @@ jobs:
|
|||||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||||
- name: Restore Python virtual environment
|
- name: Restore Python virtual environment
|
||||||
id: cache-venv
|
id: cache-venv
|
||||||
uses: actions/cache@v4.2.4
|
uses: actions/cache@v4.2.3
|
||||||
with:
|
with:
|
||||||
path: venv
|
path: venv
|
||||||
key: |
|
key: |
|
||||||
@@ -131,7 +131,7 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
- name: Restore pre-commit environment from cache
|
- name: Restore pre-commit environment from cache
|
||||||
id: cache-precommit
|
id: cache-precommit
|
||||||
uses: actions/cache@v4.2.4
|
uses: actions/cache@v4.2.3
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||||
key: |
|
key: |
|
||||||
@@ -177,7 +177,7 @@ jobs:
|
|||||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||||
- name: Restore Python virtual environment
|
- name: Restore Python virtual environment
|
||||||
id: cache-venv
|
id: cache-venv
|
||||||
uses: actions/cache@v4.2.4
|
uses: actions/cache@v4.2.3
|
||||||
with:
|
with:
|
||||||
path: venv
|
path: venv
|
||||||
key: |
|
key: |
|
||||||
@@ -189,7 +189,7 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
- name: Restore pre-commit environment from cache
|
- name: Restore pre-commit environment from cache
|
||||||
id: cache-precommit
|
id: cache-precommit
|
||||||
uses: actions/cache@v4.2.4
|
uses: actions/cache@v4.2.3
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||||
key: |
|
key: |
|
||||||
@@ -221,7 +221,7 @@ jobs:
|
|||||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||||
- name: Restore Python virtual environment
|
- name: Restore Python virtual environment
|
||||||
id: cache-venv
|
id: cache-venv
|
||||||
uses: actions/cache@v4.2.4
|
uses: actions/cache@v4.2.3
|
||||||
with:
|
with:
|
||||||
path: venv
|
path: venv
|
||||||
key: |
|
key: |
|
||||||
@@ -233,7 +233,7 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
- name: Restore pre-commit environment from cache
|
- name: Restore pre-commit environment from cache
|
||||||
id: cache-precommit
|
id: cache-precommit
|
||||||
uses: actions/cache@v4.2.4
|
uses: actions/cache@v4.2.3
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||||
key: |
|
key: |
|
||||||
@@ -265,7 +265,7 @@ jobs:
|
|||||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||||
- name: Restore Python virtual environment
|
- name: Restore Python virtual environment
|
||||||
id: cache-venv
|
id: cache-venv
|
||||||
uses: actions/cache@v4.2.4
|
uses: actions/cache@v4.2.3
|
||||||
with:
|
with:
|
||||||
path: venv
|
path: venv
|
||||||
key: |
|
key: |
|
||||||
@@ -307,7 +307,7 @@ jobs:
|
|||||||
echo "key=mypy-${{ env.MYPY_CACHE_VERSION }}-$mypy_version-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
|
echo "key=mypy-${{ env.MYPY_CACHE_VERSION }}-$mypy_version-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
|
||||||
- name: Restore Python virtual environment
|
- name: Restore Python virtual environment
|
||||||
id: cache-venv
|
id: cache-venv
|
||||||
uses: actions/cache@v4.2.4
|
uses: actions/cache@v4.2.3
|
||||||
with:
|
with:
|
||||||
path: venv
|
path: venv
|
||||||
key: >-
|
key: >-
|
||||||
@@ -318,7 +318,7 @@ jobs:
|
|||||||
echo "Failed to restore Python virtual environment from cache"
|
echo "Failed to restore Python virtual environment from cache"
|
||||||
exit 1
|
exit 1
|
||||||
- name: Restore mypy cache
|
- name: Restore mypy cache
|
||||||
uses: actions/cache@v4.2.4
|
uses: actions/cache@v4.2.3
|
||||||
with:
|
with:
|
||||||
path: .mypy_cache
|
path: .mypy_cache
|
||||||
key: >-
|
key: >-
|
||||||
@@ -351,7 +351,7 @@ jobs:
|
|||||||
cosign-release: "v2.4.3"
|
cosign-release: "v2.4.3"
|
||||||
- name: Restore Python virtual environment
|
- name: Restore Python virtual environment
|
||||||
id: cache-venv
|
id: cache-venv
|
||||||
uses: actions/cache@v4.2.4
|
uses: actions/cache@v4.2.3
|
||||||
with:
|
with:
|
||||||
path: venv
|
path: venv
|
||||||
key: |
|
key: |
|
||||||
@@ -406,7 +406,7 @@ jobs:
|
|||||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||||
- name: Restore Python virtual environment
|
- name: Restore Python virtual environment
|
||||||
id: cache-venv
|
id: cache-venv
|
||||||
uses: actions/cache@v4.2.4
|
uses: actions/cache@v4.2.3
|
||||||
with:
|
with:
|
||||||
path: venv
|
path: venv
|
||||||
key: |
|
key: |
|
||||||
|
@@ -8,7 +8,7 @@ pytest-asyncio==0.25.2
|
|||||||
pytest-cov==6.2.1
|
pytest-cov==6.2.1
|
||||||
pytest-timeout==2.4.0
|
pytest-timeout==2.4.0
|
||||||
pytest==8.4.1
|
pytest==8.4.1
|
||||||
ruff==0.12.8
|
ruff==0.12.7
|
||||||
time-machine==2.17.0
|
time-machine==2.17.0
|
||||||
types-docker==7.1.0.20250705
|
types-docker==7.1.0.20250705
|
||||||
types-pyyaml==6.0.12.20250516
|
types-pyyaml==6.0.12.20250516
|
||||||
|
@@ -11,13 +11,12 @@ import shutil
|
|||||||
from awesomeversion import AwesomeVersion
|
from awesomeversion import AwesomeVersion
|
||||||
import jinja2
|
import jinja2
|
||||||
|
|
||||||
from ..const import AddonState, LogLevel
|
from ..const import LogLevel
|
||||||
from ..coresys import CoreSys
|
from ..coresys import CoreSys
|
||||||
from ..docker.audio import DockerAudio
|
from ..docker.audio import DockerAudio
|
||||||
from ..docker.const import ContainerState
|
from ..docker.const import ContainerState
|
||||||
from ..docker.stats import DockerStats
|
from ..docker.stats import DockerStats
|
||||||
from ..exceptions import (
|
from ..exceptions import (
|
||||||
AddonsError,
|
|
||||||
AudioError,
|
AudioError,
|
||||||
AudioJobError,
|
AudioJobError,
|
||||||
AudioUpdateError,
|
AudioUpdateError,
|
||||||
@@ -132,9 +131,6 @@ class PluginAudio(PluginBase):
|
|||||||
except (DockerError, PluginError) as err:
|
except (DockerError, PluginError) as err:
|
||||||
raise AudioUpdateError("Audio update failed", _LOGGER.error) from err
|
raise AudioUpdateError("Audio update failed", _LOGGER.error) from err
|
||||||
|
|
||||||
# Restart add-ons with audio support after plugin update
|
|
||||||
await self._restart_audio_addons()
|
|
||||||
|
|
||||||
async def restart(self) -> None:
|
async def restart(self) -> None:
|
||||||
"""Restart Audio plugin."""
|
"""Restart Audio plugin."""
|
||||||
_LOGGER.info("Restarting Audio plugin")
|
_LOGGER.info("Restarting Audio plugin")
|
||||||
@@ -144,9 +140,6 @@ class PluginAudio(PluginBase):
|
|||||||
except DockerError as err:
|
except DockerError as err:
|
||||||
raise AudioError("Can't start Audio plugin", _LOGGER.error) from err
|
raise AudioError("Can't start Audio plugin", _LOGGER.error) from err
|
||||||
|
|
||||||
# Restart add-ons with audio support after plugin restart
|
|
||||||
await self._restart_audio_addons()
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Run Audio plugin."""
|
"""Run Audio plugin."""
|
||||||
_LOGGER.info("Starting Audio plugin")
|
_LOGGER.info("Starting Audio plugin")
|
||||||
@@ -210,35 +203,6 @@ class PluginAudio(PluginBase):
|
|||||||
f"Can't update pulse audio config: {err}", _LOGGER.error
|
f"Can't update pulse audio config: {err}", _LOGGER.error
|
||||||
) from err
|
) from err
|
||||||
|
|
||||||
async def _restart_audio_addons(self) -> None:
|
|
||||||
"""Restart all installed add-ons that have audio support."""
|
|
||||||
audio_addons = [
|
|
||||||
addon
|
|
||||||
for addon in self.sys_addons.installed
|
|
||||||
if addon.with_audio and addon.state == AddonState.STARTED
|
|
||||||
]
|
|
||||||
|
|
||||||
if not audio_addons:
|
|
||||||
_LOGGER.debug("No running audio add-ons to restart")
|
|
||||||
return
|
|
||||||
|
|
||||||
_LOGGER.info(
|
|
||||||
"Restarting %d audio add-ons after audio plugin restart: %s",
|
|
||||||
len(audio_addons),
|
|
||||||
[addon.slug for addon in audio_addons],
|
|
||||||
)
|
|
||||||
|
|
||||||
for addon in audio_addons:
|
|
||||||
try:
|
|
||||||
_LOGGER.info("Restarting audio add-on: %s", addon.slug)
|
|
||||||
await addon.restart()
|
|
||||||
except AddonsError as err:
|
|
||||||
_LOGGER.warning(
|
|
||||||
"Failed to restart audio add-on %s after audio plugin restart: %s",
|
|
||||||
addon.slug,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Job(
|
@Job(
|
||||||
name="plugin_audio_restart_after_problem",
|
name="plugin_audio_restart_after_problem",
|
||||||
throttle_period=WATCHDOG_THROTTLE_PERIOD,
|
throttle_period=WATCHDOG_THROTTLE_PERIOD,
|
||||||
|
@@ -1,9 +1,11 @@
|
|||||||
"""Init file for Supervisor add-on Git."""
|
"""Init file for Supervisor add-on Git."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from enum import StrEnum
|
||||||
import errno
|
import errno
|
||||||
import functools as ft
|
import functools as ft
|
||||||
import logging
|
import logging
|
||||||
|
from os import listdir
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
@@ -20,6 +22,32 @@ from .validate import RE_REPOSITORY
|
|||||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class _RepoDirectoryStatus(StrEnum):
|
||||||
|
"""Basic directory status for repo."""
|
||||||
|
|
||||||
|
GOOD = "good"
|
||||||
|
EMPTY = "empty"
|
||||||
|
MISSING_GIT = "missing_git"
|
||||||
|
|
||||||
|
|
||||||
|
def _check_repo_directory(path: Path) -> _RepoDirectoryStatus:
|
||||||
|
"""Check repository directory.
|
||||||
|
|
||||||
|
Must be run in executor.
|
||||||
|
"""
|
||||||
|
if not path.is_dir():
|
||||||
|
return _RepoDirectoryStatus.EMPTY
|
||||||
|
|
||||||
|
if (path / ".git").is_dir():
|
||||||
|
return _RepoDirectoryStatus.GOOD
|
||||||
|
|
||||||
|
return (
|
||||||
|
_RepoDirectoryStatus.MISSING_GIT
|
||||||
|
if listdir(path)
|
||||||
|
else _RepoDirectoryStatus.EMPTY
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class GitRepo(CoreSysAttributes):
|
class GitRepo(CoreSysAttributes):
|
||||||
"""Manage Add-on Git repository."""
|
"""Manage Add-on Git repository."""
|
||||||
|
|
||||||
@@ -50,9 +78,14 @@ class GitRepo(CoreSysAttributes):
|
|||||||
|
|
||||||
async def load(self) -> None:
|
async def load(self) -> None:
|
||||||
"""Init Git add-on repository."""
|
"""Init Git add-on repository."""
|
||||||
if not await self.sys_run_in_executor((self.path / ".git").is_dir):
|
match await self.sys_run_in_executor(_check_repo_directory, self.path):
|
||||||
await self.clone()
|
# Nothing cached. Set up as fresh clone
|
||||||
return
|
case _RepoDirectoryStatus.EMPTY:
|
||||||
|
await self.clone()
|
||||||
|
return
|
||||||
|
# Repository is corrupt. Try to reset it before loading
|
||||||
|
case _RepoDirectoryStatus.MISSING_GIT:
|
||||||
|
await self.reset()
|
||||||
|
|
||||||
# Load repository
|
# Load repository
|
||||||
async with self.lock:
|
async with self.lock:
|
||||||
@@ -175,6 +208,22 @@ class GitRepo(CoreSysAttributes):
|
|||||||
async with self.lock:
|
async with self.lock:
|
||||||
_LOGGER.info("Update add-on %s repository from %s", self.path, self.url)
|
_LOGGER.info("Update add-on %s repository from %s", self.path, self.url)
|
||||||
|
|
||||||
|
# .git is missing, repository is corrupted. Can't continue, raise issue
|
||||||
|
if (
|
||||||
|
await self.sys_run_in_executor(_check_repo_directory, self.path)
|
||||||
|
!= _RepoDirectoryStatus.GOOD
|
||||||
|
):
|
||||||
|
self.sys_resolution.create_issue(
|
||||||
|
IssueType.CORRUPT_REPOSITORY,
|
||||||
|
ContextType.STORE,
|
||||||
|
reference=self.path.stem,
|
||||||
|
suggestions=[SuggestionType.EXECUTE_RESET],
|
||||||
|
)
|
||||||
|
raise StoreGitError(
|
||||||
|
f"Can't update {self.url} repo because git information is missing",
|
||||||
|
_LOGGER.error,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
git_cmd = git.Git()
|
git_cmd = git.Git()
|
||||||
await self.sys_run_in_executor(git_cmd.ls_remote, "--heads", self.url)
|
await self.sys_run_in_executor(git_cmd.ls_remote, "--heads", self.url)
|
||||||
|
@@ -337,10 +337,32 @@ async def fixture_all_dbus_services(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def addon_repo_fixtures() -> dict[str, Path]:
|
||||||
|
"""Make addon repo fixtures into valid repositories and return."""
|
||||||
|
addon_repo_fixtures = Path(__file__).parent.joinpath("fixtures") / "addons"
|
||||||
|
core_repo_fixture = addon_repo_fixtures / "core"
|
||||||
|
local_repo_fixture = addon_repo_fixtures / "local"
|
||||||
|
git_repo_fixtures = addon_repo_fixtures / "git"
|
||||||
|
|
||||||
|
# Ensure each repo folder has a dummy .git
|
||||||
|
(core_repo_fixture / ".git").mkdir(exist_ok=True)
|
||||||
|
for f in os.listdir(git_repo_fixtures):
|
||||||
|
if (repo := git_repo_fixtures / f).is_dir():
|
||||||
|
(repo / ".git").mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"core": core_repo_fixture,
|
||||||
|
"local": local_repo_fixture,
|
||||||
|
"git": git_repo_fixtures,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def coresys(
|
async def coresys(
|
||||||
docker,
|
docker,
|
||||||
dbus_session_bus,
|
dbus_session_bus,
|
||||||
|
addon_repo_fixtures: dict[str, Path],
|
||||||
all_dbus_services,
|
all_dbus_services,
|
||||||
aiohttp_client,
|
aiohttp_client,
|
||||||
run_supervisor_state,
|
run_supervisor_state,
|
||||||
@@ -393,15 +415,9 @@ async def coresys(
|
|||||||
coresys_obj.host.network._connectivity = True
|
coresys_obj.host.network._connectivity = True
|
||||||
|
|
||||||
# Fix Paths
|
# Fix Paths
|
||||||
su_config.ADDONS_CORE = Path(
|
su_config.ADDONS_CORE = addon_repo_fixtures["core"]
|
||||||
Path(__file__).parent.joinpath("fixtures"), "addons/core"
|
su_config.ADDONS_LOCAL = addon_repo_fixtures["local"]
|
||||||
)
|
su_config.ADDONS_GIT = addon_repo_fixtures["git"]
|
||||||
su_config.ADDONS_LOCAL = Path(
|
|
||||||
Path(__file__).parent.joinpath("fixtures"), "addons/local"
|
|
||||||
)
|
|
||||||
su_config.ADDONS_GIT = Path(
|
|
||||||
Path(__file__).parent.joinpath("fixtures"), "addons/git"
|
|
||||||
)
|
|
||||||
su_config.APPARMOR_DATA = Path(
|
su_config.APPARMOR_DATA = Path(
|
||||||
Path(__file__).parent.joinpath("fixtures"), "apparmor"
|
Path(__file__).parent.joinpath("fixtures"), "apparmor"
|
||||||
)
|
)
|
||||||
|
@@ -2,15 +2,13 @@
|
|||||||
|
|
||||||
import errno
|
import errno
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, Mock, PropertyMock, patch
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
from awesomeversion import AwesomeVersion
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from supervisor.const import AddonState, LogLevel
|
from supervisor.const import LogLevel
|
||||||
from supervisor.coresys import CoreSys
|
from supervisor.coresys import CoreSys
|
||||||
from supervisor.docker.audio import DockerAudio
|
from supervisor.docker.audio import DockerAudio
|
||||||
from supervisor.exceptions import AddonsError, JobException
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="docker_interface")
|
@pytest.fixture(name="docker_interface")
|
||||||
@@ -87,189 +85,3 @@ async def test_load_error(
|
|||||||
assert "Can't read pulse-client.tmpl" in caplog.text
|
assert "Can't read pulse-client.tmpl" in caplog.text
|
||||||
assert "Can't create default asound" in caplog.text
|
assert "Can't create default asound" in caplog.text
|
||||||
assert coresys.core.healthy is False
|
assert coresys.core.healthy is False
|
||||||
|
|
||||||
|
|
||||||
async def test_restart_audio_addons_no_audio_addons(
|
|
||||||
coresys: CoreSys,
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
):
|
|
||||||
"""Test _restart_audio_addons with no audio add-ons installed."""
|
|
||||||
# Mock empty installed add-ons list
|
|
||||||
with patch.object(
|
|
||||||
type(coresys.addons), "installed", new_callable=PropertyMock
|
|
||||||
) as mock_installed:
|
|
||||||
mock_installed.return_value = []
|
|
||||||
# Should complete without errors and not attempt to restart any add-ons
|
|
||||||
await coresys.plugins.audio._restart_audio_addons()
|
|
||||||
|
|
||||||
# Verify the method returned successfully (no exceptions raised)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_restart_audio_addons_with_audio_addons(
|
|
||||||
coresys: CoreSys,
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
):
|
|
||||||
"""Test _restart_audio_addons with audio add-ons installed."""
|
|
||||||
# Create mock audio add-ons
|
|
||||||
audio_addon_1 = Mock()
|
|
||||||
audio_addon_1.with_audio = True
|
|
||||||
audio_addon_1.state = AddonState.STARTED
|
|
||||||
audio_addon_1.slug = "audio_addon_1"
|
|
||||||
audio_addon_1.restart = AsyncMock()
|
|
||||||
|
|
||||||
audio_addon_2 = Mock()
|
|
||||||
audio_addon_2.with_audio = True
|
|
||||||
audio_addon_2.state = AddonState.STARTED
|
|
||||||
audio_addon_2.slug = "audio_addon_2"
|
|
||||||
audio_addon_2.restart = AsyncMock()
|
|
||||||
|
|
||||||
# Create non-audio add-on that should be ignored
|
|
||||||
non_audio_addon = Mock()
|
|
||||||
non_audio_addon.with_audio = False
|
|
||||||
non_audio_addon.state = AddonState.STARTED
|
|
||||||
non_audio_addon.slug = "non_audio_addon"
|
|
||||||
non_audio_addon.restart = AsyncMock()
|
|
||||||
|
|
||||||
# Create stopped audio add-on that should be ignored
|
|
||||||
stopped_audio_addon = Mock()
|
|
||||||
stopped_audio_addon.with_audio = True
|
|
||||||
stopped_audio_addon.state = AddonState.STOPPED
|
|
||||||
stopped_audio_addon.slug = "stopped_audio_addon"
|
|
||||||
stopped_audio_addon.restart = AsyncMock()
|
|
||||||
|
|
||||||
mock_addons = [audio_addon_1, audio_addon_2, non_audio_addon, stopped_audio_addon]
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
type(coresys.addons), "installed", new_callable=PropertyMock
|
|
||||||
) as mock_installed:
|
|
||||||
mock_installed.return_value = mock_addons
|
|
||||||
await coresys.plugins.audio._restart_audio_addons()
|
|
||||||
|
|
||||||
# Verify only audio add-ons in STARTED state were restarted
|
|
||||||
audio_addon_1.restart.assert_called_once()
|
|
||||||
audio_addon_2.restart.assert_called_once()
|
|
||||||
non_audio_addon.restart.assert_not_called()
|
|
||||||
stopped_audio_addon.restart.assert_not_called()
|
|
||||||
|
|
||||||
assert "Restarting 2 audio add-ons after audio plugin restart" in caplog.text
|
|
||||||
assert "audio_addon_1" in caplog.text
|
|
||||||
assert "audio_addon_2" in caplog.text
|
|
||||||
assert "Restarting audio add-on: audio_addon_1" in caplog.text
|
|
||||||
assert "Restarting audio add-on: audio_addon_2" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
async def test_restart_audio_addons_with_error_handling(
|
|
||||||
coresys: CoreSys,
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
):
|
|
||||||
"""Test _restart_audio_addons handles add-on restart errors gracefully."""
|
|
||||||
# Create mock audio add-ons - one succeeds, one fails
|
|
||||||
successful_addon = Mock()
|
|
||||||
successful_addon.with_audio = True
|
|
||||||
successful_addon.state = AddonState.STARTED
|
|
||||||
successful_addon.slug = "successful_addon"
|
|
||||||
successful_addon.restart = AsyncMock()
|
|
||||||
|
|
||||||
failing_addon = Mock()
|
|
||||||
failing_addon.with_audio = True
|
|
||||||
failing_addon.state = AddonState.STARTED
|
|
||||||
failing_addon.slug = "failing_addon"
|
|
||||||
failing_addon.restart = AsyncMock(side_effect=AddonsError("Test error"))
|
|
||||||
|
|
||||||
mock_addons = [successful_addon, failing_addon]
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.object(
|
|
||||||
type(coresys.addons), "installed", new_callable=PropertyMock
|
|
||||||
) as mock_installed,
|
|
||||||
):
|
|
||||||
mock_installed.return_value = mock_addons
|
|
||||||
await coresys.plugins.audio._restart_audio_addons()
|
|
||||||
|
|
||||||
# Verify both add-ons were attempted to be restarted
|
|
||||||
successful_addon.restart.assert_called_once()
|
|
||||||
failing_addon.restart.assert_called_once()
|
|
||||||
|
|
||||||
# Verify error was captured and logged
|
|
||||||
assert "Failed to restart audio add-on failing_addon" in caplog.text
|
|
||||||
assert "Restarting audio add-on: successful_addon" in caplog.text
|
|
||||||
assert "Restarting audio add-on: failing_addon" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
async def test_restart_calls_restart_audio_addons(
|
|
||||||
coresys: CoreSys,
|
|
||||||
docker_interface: tuple[AsyncMock, AsyncMock],
|
|
||||||
write_json: Mock,
|
|
||||||
):
|
|
||||||
"""Test that restart() method calls _restart_audio_addons."""
|
|
||||||
with patch.object(
|
|
||||||
coresys.plugins.audio, "_restart_audio_addons"
|
|
||||||
) as mock_restart_addons:
|
|
||||||
await coresys.plugins.audio.restart()
|
|
||||||
|
|
||||||
# Verify docker restart was called
|
|
||||||
docker_interface[1].assert_called_once()
|
|
||||||
|
|
||||||
# Verify _restart_audio_addons was called after plugin restart
|
|
||||||
mock_restart_addons.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_update_calls_restart_audio_addons(
|
|
||||||
coresys: CoreSys,
|
|
||||||
):
|
|
||||||
"""Test that update() method calls _restart_audio_addons after successful update."""
|
|
||||||
with (
|
|
||||||
patch.object(
|
|
||||||
type(coresys.plugins.audio), "latest_version", new_callable=PropertyMock
|
|
||||||
) as mock_latest_version,
|
|
||||||
patch.object(
|
|
||||||
type(coresys.plugins.audio), "version", new_callable=PropertyMock
|
|
||||||
) as mock_version,
|
|
||||||
patch("supervisor.plugins.base.PluginBase.update") as mock_super_update,
|
|
||||||
patch.object(
|
|
||||||
coresys.plugins.audio, "_restart_audio_addons"
|
|
||||||
) as mock_restart_addons,
|
|
||||||
patch("supervisor.jobs.decorator.Job.check_conditions", return_value=None),
|
|
||||||
):
|
|
||||||
mock_latest_version.return_value = AwesomeVersion("1.2.3")
|
|
||||||
mock_version.return_value = AwesomeVersion("1.2.2")
|
|
||||||
mock_super_update.return_value = None
|
|
||||||
|
|
||||||
await coresys.plugins.audio.update()
|
|
||||||
|
|
||||||
# Verify super().update() was called
|
|
||||||
mock_super_update.assert_called_once()
|
|
||||||
|
|
||||||
# Verify _restart_audio_addons was called after successful update
|
|
||||||
mock_restart_addons.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_update_does_not_restart_addons_on_failure(
|
|
||||||
coresys: CoreSys,
|
|
||||||
):
|
|
||||||
"""Test that update() method does not call _restart_audio_addons when update fails."""
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.object(
|
|
||||||
type(coresys.plugins.audio), "latest_version", new_callable=PropertyMock
|
|
||||||
) as mock_latest_version,
|
|
||||||
patch.object(
|
|
||||||
type(coresys.plugins.audio), "version", new_callable=PropertyMock
|
|
||||||
) as mock_version,
|
|
||||||
patch(
|
|
||||||
"supervisor.plugins.base.PluginBase.update",
|
|
||||||
side_effect=Exception("Update failed"),
|
|
||||||
),
|
|
||||||
patch.object(
|
|
||||||
coresys.plugins.audio, "_restart_audio_addons"
|
|
||||||
) as mock_restart_addons,
|
|
||||||
patch("supervisor.jobs.decorator.Job.check_conditions", return_value=None),
|
|
||||||
pytest.raises(JobException), # Job decorator wraps exceptions in JobException
|
|
||||||
):
|
|
||||||
mock_latest_version.return_value = AwesomeVersion("1.2.3")
|
|
||||||
mock_version.return_value = AwesomeVersion("1.2.2")
|
|
||||||
|
|
||||||
await coresys.plugins.audio.update()
|
|
||||||
|
|
||||||
# Verify _restart_audio_addons was NOT called due to update failure
|
|
||||||
mock_restart_addons.assert_not_called()
|
|
||||||
|
@@ -3,13 +3,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
from git import GitCommandError, InvalidGitRepositoryError, NoSuchPathError
|
from git import GitCommandError, InvalidGitRepositoryError, NoSuchPathError
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from supervisor.coresys import CoreSys
|
from supervisor.coresys import CoreSys
|
||||||
from supervisor.exceptions import StoreGitCloneError, StoreGitError
|
from supervisor.exceptions import StoreGitCloneError, StoreGitError
|
||||||
|
from supervisor.resolution.const import ContextType, IssueType, SuggestionType
|
||||||
|
from supervisor.resolution.data import Issue, Suggestion
|
||||||
from supervisor.store.git import GitRepo
|
from supervisor.store.git import GitRepo
|
||||||
|
|
||||||
REPO_URL = "https://github.com/awesome-developer/awesome-repo"
|
REPO_URL = "https://github.com/awesome-developer/awesome-repo"
|
||||||
@@ -93,6 +95,70 @@ async def test_git_load(coresys: CoreSys, tmp_path: Path):
|
|||||||
assert mock_repo.call_count == 1
|
assert mock_repo.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("tmp_supervisor_data", "supervisor_internet")
|
||||||
|
async def test_git_load_corrupt(coresys: CoreSys, tmp_path: Path):
|
||||||
|
"""Test git load with corrupt repo."""
|
||||||
|
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
||||||
|
repo_dir = tmp_path / "repo"
|
||||||
|
repo = GitRepo(coresys, repo_dir, REPO_URL)
|
||||||
|
|
||||||
|
# Pretend we have a something but not .git to force a reset
|
||||||
|
repo_dir.mkdir()
|
||||||
|
marker = repo_dir / "test.txt"
|
||||||
|
marker.touch()
|
||||||
|
|
||||||
|
def mock_clone_from(url, path, *args, **kwargs):
|
||||||
|
"""Mock to just make a .git and return."""
|
||||||
|
Path(path, ".git").mkdir()
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
with patch("git.Repo") as mock_repo:
|
||||||
|
mock_repo.clone_from = mock_clone_from
|
||||||
|
await repo.load()
|
||||||
|
assert mock_repo.call_count == 1
|
||||||
|
assert not marker.exists()
|
||||||
|
assert (repo_dir / ".git").is_dir()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("tmp_supervisor_data", "supervisor_internet")
|
||||||
|
async def test_git_pull_correct(coresys: CoreSys, tmp_path: Path):
|
||||||
|
"""Test git pull with corrupt repo."""
|
||||||
|
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
||||||
|
repo_dir = tmp_path / "repo"
|
||||||
|
repo = GitRepo(coresys, repo_dir, REPO_URL)
|
||||||
|
|
||||||
|
# Set up a our fake repo
|
||||||
|
repo_dir.mkdir()
|
||||||
|
git_dir = repo_dir / ".git"
|
||||||
|
git_dir.mkdir()
|
||||||
|
(repo_dir / "test.txt").touch()
|
||||||
|
|
||||||
|
with patch("git.Repo"):
|
||||||
|
await repo.load()
|
||||||
|
|
||||||
|
# Make it corrupt
|
||||||
|
git_dir.rmdir()
|
||||||
|
|
||||||
|
# Check that we get an issue on pull
|
||||||
|
with pytest.raises(
|
||||||
|
StoreGitError,
|
||||||
|
match=f"Can't update {REPO_URL} repo because git information is missing",
|
||||||
|
):
|
||||||
|
await repo.pull()
|
||||||
|
assert (
|
||||||
|
Issue(
|
||||||
|
IssueType.CORRUPT_REPOSITORY, ContextType.STORE, reference=repo_dir.stem
|
||||||
|
)
|
||||||
|
in coresys.resolution.issues
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
Suggestion(
|
||||||
|
SuggestionType.EXECUTE_RESET, ContextType.STORE, reference=repo_dir.stem
|
||||||
|
)
|
||||||
|
in coresys.resolution.suggestions
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"git_errors",
|
"git_errors",
|
||||||
[
|
[
|
||||||
|
Reference in New Issue
Block a user