mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-08-19 22:19:21 +00:00
Compare commits
20 Commits
2024.05.2
...
need-updat
Author | SHA1 | Date | |
---|---|---|---|
![]() |
b7c53d9e40 | ||
![]() |
b684c8673e | ||
![]() |
547f42439d | ||
![]() |
c51ceb000f | ||
![]() |
4cbede1bc8 | ||
![]() |
5eac8c7780 | ||
![]() |
ab78d87304 | ||
![]() |
09166e3867 | ||
![]() |
8a5c813cdd | ||
![]() |
4200622f43 | ||
![]() |
c4452a85b4 | ||
![]() |
e57de4a3c1 | ||
![]() |
9fd2c91c55 | ||
![]() |
fbd70013a8 | ||
![]() |
8d18f3e66e | ||
![]() |
5f5754e860 | ||
![]() |
974c882b9a | ||
![]() |
a9ea90096b | ||
![]() |
45c72c426e | ||
![]() |
4e5b75fe19 |
2
.github/workflows/builder.yml
vendored
2
.github/workflows/builder.yml
vendored
@@ -149,7 +149,7 @@ jobs:
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: needs.init.outputs.publish == 'true'
|
||||
uses: docker/login-action@v3.1.0
|
||||
uses: docker/login-action@v3.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
|
@@ -8,7 +8,7 @@ brotli==1.1.0
|
||||
ciso8601==2.3.1
|
||||
colorlog==6.8.2
|
||||
cpe==1.2.1
|
||||
cryptography==42.0.7
|
||||
cryptography==42.0.8
|
||||
debugpy==1.8.1
|
||||
deepmerge==1.1.1
|
||||
dirhash==0.4.0
|
||||
@@ -20,11 +20,11 @@ orjson==3.9.15
|
||||
pulsectl==24.4.0
|
||||
pyudev==0.24.3
|
||||
PyYAML==6.0.1
|
||||
requests==2.32.2
|
||||
requests==2.32.3
|
||||
securetar==2024.2.1
|
||||
sentry-sdk==2.3.1
|
||||
sentry-sdk==2.5.1
|
||||
setuptools==70.0.0
|
||||
voluptuous==0.14.2
|
||||
dbus-fast==2.21.3
|
||||
typing_extensions==4.12.0
|
||||
typing_extensions==4.12.2
|
||||
zlib-fast==0.2.0
|
||||
|
@@ -1,12 +1,12 @@
|
||||
coverage==7.5.1
|
||||
coverage==7.5.3
|
||||
pre-commit==3.7.1
|
||||
pylint==3.2.2
|
||||
pylint==3.2.3
|
||||
pytest-aiohttp==1.0.5
|
||||
pytest-asyncio==0.23.6
|
||||
pytest-cov==5.0.0
|
||||
pytest-timeout==2.3.1
|
||||
pytest==8.2.1
|
||||
ruff==0.4.5
|
||||
pytest==8.2.2
|
||||
ruff==0.4.8
|
||||
time-machine==2.14.1
|
||||
typing_extensions==4.12.0
|
||||
typing_extensions==4.12.2
|
||||
urllib3==2.2.1
|
||||
|
@@ -285,9 +285,13 @@ class Addon(AddonModel):
|
||||
@property
|
||||
def need_update(self) -> bool:
|
||||
"""Return True if an update is available."""
|
||||
if self.is_detached:
|
||||
if self.is_detached or self.version == self.latest_version:
|
||||
return False
|
||||
return self.version != self.latest_version
|
||||
|
||||
with suppress(AddonsNotSupportedError):
|
||||
self._validate_availability(self.data_store)
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def dns(self) -> list[str]:
|
||||
@@ -1343,11 +1347,11 @@ class Addon(AddonModel):
|
||||
)
|
||||
raise AddonsError() from err
|
||||
|
||||
finally:
|
||||
# Is add-on loaded
|
||||
if not self.loaded:
|
||||
await self.load()
|
||||
|
||||
finally:
|
||||
# Run add-on
|
||||
if data[ATTR_STATE] == AddonState.STARTED:
|
||||
wait_for_start = await self.start()
|
||||
|
@@ -182,9 +182,13 @@ class APIHomeAssistant(CoreSysAttributes):
|
||||
)
|
||||
|
||||
@api_process
|
||||
def rebuild(self, request: web.Request) -> Awaitable[None]:
|
||||
async def rebuild(self, request: web.Request) -> None:
|
||||
"""Rebuild Home Assistant."""
|
||||
return asyncio.shield(self.sys_homeassistant.core.rebuild())
|
||||
body = await api_validate(SCHEMA_RESTART, request)
|
||||
|
||||
await asyncio.shield(
|
||||
self.sys_homeassistant.core.rebuild(safe_mode=body[ATTR_SAFE_MODE])
|
||||
)
|
||||
|
||||
@api_process
|
||||
async def check(self, request: web.Request) -> None:
|
||||
|
@@ -345,9 +345,6 @@ class Core(CoreSysAttributes):
|
||||
if self.state == CoreState.RUNNING:
|
||||
self.state = CoreState.SHUTDOWN
|
||||
|
||||
# Stop docker monitoring
|
||||
await self.sys_docker.unload()
|
||||
|
||||
# Shutdown Application Add-ons, using Home Assistant API
|
||||
await self.sys_addons.shutdown(AddonStartup.APPLICATION)
|
||||
|
||||
|
@@ -103,7 +103,13 @@ class HardwareManager(CoreSysAttributes):
|
||||
# Exctract all devices
|
||||
for device in self._udev.list_devices():
|
||||
# Skip devices without mapping
|
||||
if not device.device_node or self.helper.hide_virtual_device(device):
|
||||
try:
|
||||
if not device.device_node or self.helper.hide_virtual_device(device):
|
||||
continue
|
||||
except UnicodeDecodeError as err:
|
||||
# Some udev properties have an unkown/different encoding. This is a general
|
||||
# problem with pyudev, see https://github.com/pyudev/pyudev/pull/230
|
||||
_LOGGER.warning("Ignoring udev device due to error: %s", err)
|
||||
continue
|
||||
self._devices[device.sys_name] = Device.import_udev(device)
|
||||
|
||||
|
@@ -367,6 +367,7 @@ class HomeAssistantCore(JobGroup):
|
||||
"""Restart Home Assistant Docker."""
|
||||
# Create safe mode marker file if necessary
|
||||
if safe_mode:
|
||||
_LOGGER.debug("Creating safe mode marker file.")
|
||||
await self.sys_run_in_executor(
|
||||
(self.sys_config.path_homeassistant / SAFE_MODE_FILENAME).touch
|
||||
)
|
||||
@@ -383,8 +384,15 @@ class HomeAssistantCore(JobGroup):
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=HomeAssistantJobError,
|
||||
)
|
||||
async def rebuild(self) -> None:
|
||||
async def rebuild(self, *, safe_mode: bool = False) -> None:
|
||||
"""Rebuild Home Assistant Docker container."""
|
||||
# Create safe mode marker file if necessary
|
||||
if safe_mode:
|
||||
_LOGGER.debug("Creating safe mode marker file.")
|
||||
await self.sys_run_in_executor(
|
||||
(self.sys_config.path_homeassistant / SAFE_MODE_FILENAME).touch
|
||||
)
|
||||
|
||||
with suppress(DockerError):
|
||||
await self.instance.stop()
|
||||
await self.start()
|
||||
|
@@ -2,7 +2,7 @@
|
||||
|
||||
from ...const import CoreState
|
||||
from ...coresys import CoreSys
|
||||
from ..const import ContextType, IssueType
|
||||
from ..const import ContextType, IssueType, SuggestionType
|
||||
from .base import CheckBase
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ class CheckDetachedAddonRemoved(CheckBase):
|
||||
IssueType.DETACHED_ADDON_REMOVED,
|
||||
ContextType.ADDON,
|
||||
reference=addon.slug,
|
||||
suggestions=[SuggestionType.EXECUTE_REMOVE],
|
||||
)
|
||||
|
||||
async def approve_check(self, reference: str | None = None) -> bool:
|
||||
|
52
supervisor/resolution/fixups/addon_execute_remove.py
Normal file
52
supervisor/resolution/fixups/addon_execute_remove.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Helpers to fix addon issue by removing it."""
|
||||
import logging
|
||||
|
||||
from ...coresys import CoreSys
|
||||
from ...exceptions import AddonsError, ResolutionFixupError
|
||||
from ..const import ContextType, IssueType, SuggestionType
|
||||
from .base import FixupBase
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup(coresys: CoreSys) -> FixupBase:
|
||||
"""Check setup function."""
|
||||
return FixupAddonExecuteRemove(coresys)
|
||||
|
||||
|
||||
class FixupAddonExecuteRemove(FixupBase):
|
||||
"""Storage class for fixup."""
|
||||
|
||||
async def process_fixup(self, reference: str | None = None) -> None:
|
||||
"""Initialize the fixup class."""
|
||||
if not (addon := self.sys_addons.get(reference, local_only=True)):
|
||||
_LOGGER.info("Addon %s already removed", reference)
|
||||
return
|
||||
|
||||
# Remove addon
|
||||
_LOGGER.info("Remove addon: %s", reference)
|
||||
try:
|
||||
addon.uninstall()
|
||||
except AddonsError as err:
|
||||
_LOGGER.error("Could not remove %s due to %s", reference, err)
|
||||
raise ResolutionFixupError() from None
|
||||
|
||||
@property
|
||||
def suggestion(self) -> SuggestionType:
|
||||
"""Return a SuggestionType enum."""
|
||||
return SuggestionType.EXECUTE_REMOVE
|
||||
|
||||
@property
|
||||
def context(self) -> ContextType:
|
||||
"""Return a ContextType enum."""
|
||||
return ContextType.ADDON
|
||||
|
||||
@property
|
||||
def issues(self) -> list[IssueType]:
|
||||
"""Return a IssueType enum list."""
|
||||
return [IssueType.DETACHED_ADDON_REMOVED]
|
||||
|
||||
@property
|
||||
def auto(self) -> bool:
|
||||
"""Return if a fixup can be apply as auto fix."""
|
||||
return False
|
@@ -4,6 +4,8 @@ import asyncio
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
|
||||
from aiohttp.test_utils import TestClient
|
||||
from awesomeversion import AwesomeVersion
|
||||
import pytest
|
||||
|
||||
from supervisor.addons.addon import Addon
|
||||
from supervisor.addons.build import AddonBuild
|
||||
@@ -285,3 +287,37 @@ async def test_api_addon_uninstall_remove_config(
|
||||
assert resp.status == 200
|
||||
assert not coresys.addons.get("local_example", local_only=True)
|
||||
assert not test_folder.exists()
|
||||
|
||||
|
||||
async def test_api_update_available_validates_version(
|
||||
api_client: TestClient,
|
||||
coresys: CoreSys,
|
||||
install_addon_example: Addon,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
tmp_supervisor_data,
|
||||
path_extern,
|
||||
):
|
||||
"""Test update available field is only true if user can update to latest version."""
|
||||
install_addon_example.data["ingress"] = False
|
||||
install_addon_example.data_store["version"] = "1.3.0"
|
||||
caplog.clear()
|
||||
|
||||
resp = await api_client.get("/addons/local_example/info")
|
||||
assert resp.status == 200
|
||||
result = await resp.json()
|
||||
assert result["data"]["version"] == "1.2.0"
|
||||
assert result["data"]["version_latest"] == "1.3.0"
|
||||
assert result["data"]["update_available"] is True
|
||||
|
||||
# If new version can't be installed due to HA version, then no update is available
|
||||
coresys.homeassistant.version = AwesomeVersion("2024.04.0")
|
||||
install_addon_example.data_store["homeassistant"] = "2024.06.0"
|
||||
|
||||
resp = await api_client.get("/addons/local_example/info")
|
||||
assert resp.status == 200
|
||||
result = await resp.json()
|
||||
assert result["data"]["version"] == "1.2.0"
|
||||
assert result["data"]["version_latest"] == "1.3.0"
|
||||
assert result["data"]["update_available"] is False
|
||||
|
||||
assert "Add-on local_example not supported" not in caplog.text
|
||||
|
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from aiohttp.test_utils import TestClient
|
||||
from awesomeversion import AwesomeVersion
|
||||
import pytest
|
||||
|
||||
from supervisor.coresys import CoreSys
|
||||
@@ -115,3 +116,29 @@ async def test_api_restart(
|
||||
|
||||
assert container.restart.call_count == 2
|
||||
assert safe_mode_marker.exists()
|
||||
|
||||
|
||||
async def test_api_rebuild(
|
||||
api_client: TestClient,
|
||||
coresys: CoreSys,
|
||||
container: MagicMock,
|
||||
tmp_supervisor_data: Path,
|
||||
path_extern,
|
||||
):
|
||||
"""Test rebuilding homeassistant."""
|
||||
coresys.homeassistant.version = AwesomeVersion("2023.09.0")
|
||||
safe_mode_marker = tmp_supervisor_data / "homeassistant" / "safe-mode"
|
||||
|
||||
with patch.object(HomeAssistantCore, "_block_till_run"):
|
||||
await api_client.post("/homeassistant/rebuild")
|
||||
|
||||
assert container.remove.call_count == 2
|
||||
container.start.assert_called_once()
|
||||
assert not safe_mode_marker.exists()
|
||||
|
||||
with patch.object(HomeAssistantCore, "_block_till_run"):
|
||||
await api_client.post("/homeassistant/rebuild", json={"safe_mode": True})
|
||||
|
||||
assert container.remove.call_count == 4
|
||||
assert container.start.call_count == 2
|
||||
assert safe_mode_marker.exists()
|
||||
|
@@ -1750,3 +1750,40 @@ async def test_reload_error(
|
||||
|
||||
assert "Could not list backups" in caplog.text
|
||||
assert coresys.core.healthy is healthy_expected
|
||||
|
||||
|
||||
async def test_monitoring_after_full_restore(
|
||||
coresys: CoreSys, full_backup_mock, install_addon_ssh, container
|
||||
):
|
||||
"""Test monitoring of addon state still works after full restore."""
|
||||
coresys.core.state = CoreState.RUNNING
|
||||
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
||||
coresys.homeassistant.core.start = AsyncMock(return_value=None)
|
||||
coresys.homeassistant.core.stop = AsyncMock(return_value=None)
|
||||
coresys.homeassistant.core.update = AsyncMock(return_value=None)
|
||||
|
||||
manager = BackupManager(coresys)
|
||||
|
||||
backup_instance = full_backup_mock.return_value
|
||||
assert await manager.do_restore_full(backup_instance)
|
||||
|
||||
backup_instance.restore_addons.assert_called_once_with([TEST_ADDON_SLUG])
|
||||
assert coresys.core.state == CoreState.RUNNING
|
||||
coresys.docker.unload.assert_not_called()
|
||||
|
||||
|
||||
async def test_monitoring_after_partial_restore(
|
||||
coresys: CoreSys, partial_backup_mock, install_addon_ssh, container
|
||||
):
|
||||
"""Test monitoring of addon state still works after full restore."""
|
||||
coresys.core.state = CoreState.RUNNING
|
||||
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
||||
|
||||
manager = BackupManager(coresys)
|
||||
|
||||
backup_instance = partial_backup_mock.return_value
|
||||
assert await manager.do_restore_partial(backup_instance, addons=[TEST_ADDON_SLUG])
|
||||
|
||||
backup_instance.restore_addons.assert_called_once_with([TEST_ADDON_SLUG])
|
||||
assert coresys.core.state == CoreState.RUNNING
|
||||
coresys.docker.unload.assert_not_called()
|
||||
|
@@ -37,6 +37,7 @@ async def test_check(coresys: CoreSys, install_addon_ssh: Addon):
|
||||
assert coresys.resolution.issues[0].type is IssueType.DETACHED_ADDON_MISSING
|
||||
assert coresys.resolution.issues[0].context is ContextType.ADDON
|
||||
assert coresys.resolution.issues[0].reference == install_addon_ssh.slug
|
||||
assert len(coresys.resolution.suggestions) == 0
|
||||
|
||||
|
||||
async def test_approve(coresys: CoreSys, install_addon_ssh: Addon):
|
||||
|
@@ -9,7 +9,7 @@ from supervisor.coresys import CoreSys
|
||||
from supervisor.resolution.checks.detached_addon_removed import (
|
||||
CheckDetachedAddonRemoved,
|
||||
)
|
||||
from supervisor.resolution.const import ContextType, IssueType
|
||||
from supervisor.resolution.const import ContextType, IssueType, SuggestionType
|
||||
|
||||
|
||||
async def test_base(coresys: CoreSys):
|
||||
@@ -28,6 +28,7 @@ async def test_check(
|
||||
|
||||
await detached_addon_removed()
|
||||
assert len(coresys.resolution.issues) == 0
|
||||
assert len(coresys.resolution.suggestions) == 0
|
||||
|
||||
(addons_dir := tmp_supervisor_data / "addons" / "local").mkdir()
|
||||
with patch.object(
|
||||
@@ -42,6 +43,11 @@ async def test_check(
|
||||
assert coresys.resolution.issues[0].context is ContextType.ADDON
|
||||
assert coresys.resolution.issues[0].reference == install_addon_ssh.slug
|
||||
|
||||
assert len(coresys.resolution.suggestions) == 1
|
||||
assert coresys.resolution.suggestions[0].type is SuggestionType.EXECUTE_REMOVE
|
||||
assert coresys.resolution.suggestions[0].context is ContextType.ADDON
|
||||
assert coresys.resolution.suggestions[0].reference == install_addon_ssh.slug
|
||||
|
||||
|
||||
async def test_approve(
|
||||
coresys: CoreSys, install_addon_ssh: Addon, tmp_supervisor_data: Path
|
||||
|
34
tests/resolution/fixup/test_addon_execute_remove.py
Normal file
34
tests/resolution/fixup/test_addon_execute_remove.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Test evaluation base."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from supervisor.addons.addon import Addon
|
||||
from supervisor.coresys import CoreSys
|
||||
from supervisor.resolution.const import ContextType, IssueType, SuggestionType
|
||||
from supervisor.resolution.data import Issue, Suggestion
|
||||
from supervisor.resolution.fixups.addon_execute_remove import FixupAddonExecuteRemove
|
||||
|
||||
|
||||
async def test_fixup(coresys: CoreSys, install_addon_ssh: Addon):
|
||||
"""Test fixup."""
|
||||
addon_execute_remove = FixupAddonExecuteRemove(coresys)
|
||||
|
||||
assert addon_execute_remove.auto is False
|
||||
|
||||
coresys.resolution.suggestions = Suggestion(
|
||||
SuggestionType.EXECUTE_REMOVE,
|
||||
ContextType.ADDON,
|
||||
reference=install_addon_ssh.slug,
|
||||
)
|
||||
coresys.resolution.issues = Issue(
|
||||
IssueType.DETACHED_ADDON_REMOVED,
|
||||
ContextType.ADDON,
|
||||
reference=install_addon_ssh.slug,
|
||||
)
|
||||
|
||||
with patch.object(Addon, "uninstall") as uninstall:
|
||||
await addon_execute_remove()
|
||||
|
||||
assert uninstall.called
|
||||
|
||||
assert len(coresys.resolution.suggestions) == 0
|
||||
assert len(coresys.resolution.issues) == 0
|
Reference in New Issue
Block a user