Compare commits

..

2 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
ef63083c08 Support Docker containerd snapshotter for image extraction progress
Co-authored-by: agners <34061+agners@users.noreply.github.com>
2025-11-11 09:42:10 +00:00
copilot-swe-agent[bot]
8ac60b7c34 Initial plan 2025-11-11 09:30:49 +00:00
18 changed files with 412 additions and 1007 deletions

View File

@@ -66,35 +66,13 @@ from ..docker.const import ContainerState
from ..docker.monitor import DockerContainerStateEvent
from ..docker.stats import DockerStats
from ..exceptions import (
AddonBackupAppArmorProfileUnknownError,
AddonBackupExportImageUnknownError,
AddonBackupMetadataInvalidError,
AddonBuildImageUnknownError,
AddonConfigurationFileUnknownError,
AddonConfigurationInvalidError,
AddonContainerRunCommandUnknownError,
AddonContainerStartUnknownError,
AddonContainerStatsUnknownError,
AddonContainerStopUnknownError,
AddonContainerWriteStdinUnknownError,
AddonCreateBackupFileUnknownError,
AddonCreateBackupMetadataFileUnknownError,
AddonExtractBackupFileUnknownError,
AddonInstallImageUnknownError,
AddonNotRunningError,
AddonConfigurationError,
AddonNotSupportedError,
AddonNotSupportedWriteStdinError,
AddonPrePostBackupCommandReturnedError,
AddonRemoveImageUnknownError,
AddonRestoreAppArmorProfileUnknownError,
AddonRestoreBackupDataUnknownError,
AddonsError,
AddonsJobError,
ConfigurationFileError,
DockerBuildError,
DockerError,
HostAppArmorError,
StoreAddonNotFoundError,
)
from ..hardware.data import Device
from ..homeassistant.const import WSEvent
@@ -257,7 +235,7 @@ class Addon(AddonModel):
await self.instance.check_image(self.version, default_image, self.arch)
except DockerError:
_LOGGER.info("No %s addon Docker image %s found", self.slug, self.image)
with suppress(DockerError, AddonNotSupportedError):
with suppress(DockerError):
await self.instance.install(self.version, default_image, arch=self.arch)
self.persist[ATTR_IMAGE] = default_image
@@ -740,16 +718,18 @@ class Addon(AddonModel):
options = self.schema.validate(self.options)
await self.sys_run_in_executor(write_json_file, self.path_options, options)
except vol.Invalid as ex:
raise AddonConfigurationInvalidError(
_LOGGER.error,
addon=self.slug,
validation_error=humanize_error(self.options, ex),
) from None
except ConfigurationFileError as err:
_LOGGER.error(
"Add-on %s has invalid options: %s",
self.slug,
humanize_error(self.options, ex),
)
except ConfigurationFileError:
_LOGGER.error("Add-on %s can't write options", self.slug)
raise AddonConfigurationFileUnknownError(addon=self.slug) from err
else:
_LOGGER.debug("Add-on %s write options: %s", self.slug, options)
return
_LOGGER.debug("Add-on %s write options: %s", self.slug, options)
raise AddonConfigurationError()
@Job(
name="addon_unload",
@@ -792,7 +772,7 @@ class Addon(AddonModel):
async def install(self) -> None:
"""Install and setup this addon."""
if not self.addon_store:
raise StoreAddonNotFoundError(addon=self.slug)
raise AddonsError("Missing from store, cannot install!")
await self.sys_addons.data.install(self.addon_store)
@@ -813,15 +793,9 @@ class Addon(AddonModel):
await self.instance.install(
self.latest_version, self.addon_store.image, arch=self.arch
)
except AddonsError:
await self.sys_addons.data.uninstall(self)
raise
except DockerBuildError as err:
await self.sys_addons.data.uninstall(self)
raise AddonBuildImageUnknownError(addon=self.slug) from err
except DockerError as err:
await self.sys_addons.data.uninstall(self)
raise AddonInstallImageUnknownError(addon=self.slug) from err
raise AddonsError() from err
# Finish initialization and set up listeners
await self.load()
@@ -845,7 +819,7 @@ class Addon(AddonModel):
try:
await self.instance.remove(remove_image=remove_image)
except DockerError as err:
raise AddonRemoveImageUnknownError(addon=self.slug) from err
raise AddonsError() from err
self.state = AddonState.UNKNOWN
@@ -910,7 +884,7 @@ class Addon(AddonModel):
if it was running. Else nothing is returned.
"""
if not self.addon_store:
raise StoreAddonNotFoundError(addon=self.slug)
raise AddonsError("Missing from store, cannot update!")
old_image = self.image
# Cache data to prevent races with other updates to global
@@ -918,10 +892,8 @@ class Addon(AddonModel):
try:
await self.instance.update(store.version, store.image, arch=self.arch)
except DockerBuildError as err:
raise AddonBuildImageUnknownError(addon=self.slug) from err
except DockerError as err:
raise AddonInstallImageUnknownError(addon=self.slug) from err
raise AddonsError() from err
# Stop the addon if running
if (last_state := self.state) in {AddonState.STARTED, AddonState.STARTUP}:
@@ -963,18 +935,12 @@ class Addon(AddonModel):
"""
last_state: AddonState = self.state
try:
# remove docker container and image but not addon config
# remove docker container but not addon config
try:
await self.instance.remove()
except DockerError as err:
raise AddonRemoveImageUnknownError(addon=self.slug) from err
try:
await self.instance.install(self.version)
except DockerBuildError as err:
raise AddonBuildImageUnknownError(addon=self.slug) from err
except DockerError as err:
raise AddonInstallImageUnknownError(addon=self.slug) from err
raise AddonsError() from err
if self.addon_store:
await self.sys_addons.data.update(self.addon_store)
@@ -1146,7 +1112,7 @@ class Addon(AddonModel):
await self.instance.run()
except DockerError as err:
self.state = AddonState.ERROR
raise AddonContainerStartUnknownError(addon=self.slug) from err
raise AddonsError() from err
return self.sys_create_task(self._wait_for_startup())
@@ -1162,7 +1128,7 @@ class Addon(AddonModel):
await self.instance.stop()
except DockerError as err:
self.state = AddonState.ERROR
raise AddonContainerStopUnknownError(addon=self.slug) from err
raise AddonsError() from err
@Job(
name="addon_restart",
@@ -1195,12 +1161,9 @@ class Addon(AddonModel):
async def stats(self) -> DockerStats:
"""Return stats of container."""
try:
if not await self.is_running():
raise AddonNotRunningError(_LOGGER.warning, addon=self.slug)
return await self.instance.stats()
except DockerError as err:
raise AddonContainerStatsUnknownError(addon=self.slug) from err
raise AddonsError() from err
@Job(
name="addon_write_stdin",
@@ -1210,15 +1173,14 @@ class Addon(AddonModel):
async def write_stdin(self, data) -> None:
"""Write data to add-on stdin."""
if not self.with_stdin:
raise AddonNotSupportedWriteStdinError(_LOGGER.error, addon=self.slug)
raise AddonNotSupportedError(
f"Add-on {self.slug} does not support writing to stdin!", _LOGGER.error
)
try:
if not await self.is_running():
raise AddonNotRunningError(_LOGGER.warning, addon=self.slug)
await self.instance.write_stdin(data)
return await self.instance.write_stdin(data)
except DockerError as err:
raise AddonContainerWriteStdinUnknownError(addon=self.slug) from err
raise AddonsError() from err
async def _backup_command(self, command: str) -> None:
try:
@@ -1227,14 +1189,15 @@ class Addon(AddonModel):
_LOGGER.debug(
"Pre-/Post backup command failed with: %s", command_return.output
)
raise AddonPrePostBackupCommandReturnedError(
_LOGGER.error, addon=self.slug, exit_code=command_return.exit_code
raise AddonsError(
f"Pre-/Post backup command returned error code: {command_return.exit_code}",
_LOGGER.error,
)
except DockerError as err:
_LOGGER.error(
"Failed running pre-/post backup command %s: %s", command, err
)
raise AddonContainerRunCommandUnknownError(addon=self.slug) from err
raise AddonsError(
f"Failed running pre-/post backup command {command}: {str(err)}",
_LOGGER.error,
) from err
@Job(
name="addon_begin_backup",
@@ -1323,17 +1286,14 @@ class Addon(AddonModel):
try:
self.instance.export_image(temp_path.joinpath("image.tar"))
except DockerError as err:
raise AddonBackupExportImageUnknownError(
addon=self.slug
) from err
raise AddonsError() from err
# Store local configs/state
try:
write_json_file(temp_path.joinpath("addon.json"), metadata)
except ConfigurationFileError as err:
_LOGGER.error("Can't save meta for %s: %s", self.slug, err)
raise AddonCreateBackupMetadataFileUnknownError(
addon=self.slug
raise AddonsError(
f"Can't save meta for {self.slug}", _LOGGER.error
) from err
# Store AppArmor Profile
@@ -1344,8 +1304,8 @@ class Addon(AddonModel):
apparmor_profile, profile_backup_file
)
except HostAppArmorError as err:
raise AddonBackupAppArmorProfileUnknownError(
addon=self.slug
raise AddonsError(
"Can't backup AppArmor profile", _LOGGER.error
) from err
# Write tarfile
@@ -1400,8 +1360,7 @@ class Addon(AddonModel):
)
_LOGGER.info("Finish backup for addon %s", self.slug)
except (tarfile.TarError, OSError, AddFileError) as err:
_LOGGER.error("Can't write backup tarfile for addon %s: %s", self.slug, err)
raise AddonCreateBackupFileUnknownError(addon=self.slug) from err
raise AddonsError(f"Can't write tarfile: {err}", _LOGGER.error) from err
finally:
if was_running:
wait_for_start = await self.end_backup()
@@ -1443,24 +1402,28 @@ class Addon(AddonModel):
try:
tmp, data = await self.sys_run_in_executor(_extract_tarfile)
except tarfile.TarError as err:
_LOGGER.error("Can't extract backup tarfile for %s: %s", self.slug, err)
raise AddonExtractBackupFileUnknownError(addon=self.slug) from err
raise AddonsError(
f"Can't read tarfile {tar_file}: {err}", _LOGGER.error
) from err
except ConfigurationFileError as err:
raise AddonConfigurationFileUnknownError(addon=self.slug) from err
raise AddonsError() from err
try:
# Validate
try:
data = SCHEMA_ADDON_BACKUP(data)
except vol.Invalid as err:
raise AddonBackupMetadataInvalidError(
raise AddonsError(
f"Can't validate {self.slug}, backup data: {humanize_error(data, err)}",
_LOGGER.error,
addon=self.slug,
validation_error=humanize_error(data, err),
) from err
# Validate availability. Raises if not
self._validate_availability(data[ATTR_SYSTEM], logger=_LOGGER.error)
# If available
if not self._available(data[ATTR_SYSTEM]):
raise AddonNotSupportedError(
f"Add-on {self.slug} is not available for this platform",
_LOGGER.error,
)
# Restore local add-on information
_LOGGER.info("Restore config for addon %s", self.slug)
@@ -1519,10 +1482,9 @@ class Addon(AddonModel):
try:
await self.sys_run_in_executor(_restore_data)
except shutil.Error as err:
_LOGGER.error(
"Can't restore origin data for %s: %s", self.slug, err
)
raise AddonRestoreBackupDataUnknownError(addon=self.slug) from err
raise AddonsError(
f"Can't restore origin data: {err}", _LOGGER.error
) from err
# Restore AppArmor
profile_file = Path(tmp.name, "apparmor.txt")
@@ -1533,13 +1495,10 @@ class Addon(AddonModel):
)
except HostAppArmorError as err:
_LOGGER.error(
"Can't restore AppArmor profile for add-on %s: %s",
"Can't restore AppArmor profile for add-on %s",
self.slug,
err,
)
raise AddonRestoreAppArmorProfileUnknownError(
addon=self.slug
) from err
raise AddonsError() from err
finally:
# Is add-on loaded

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
from functools import cached_property
import logging
from pathlib import Path
from typing import TYPE_CHECKING, Any
@@ -20,20 +19,13 @@ from ..const import (
)
from ..coresys import CoreSys, CoreSysAttributes
from ..docker.interface import MAP_ARCH
from ..exceptions import (
AddonBuildArchitectureNotSupportedError,
AddonBuildDockerfileMissingError,
ConfigurationFileError,
HassioArchNotFound,
)
from ..exceptions import ConfigurationFileError, HassioArchNotFound
from ..utils.common import FileConfiguration, find_one_filetype
from .validate import SCHEMA_BUILD_CONFIG
if TYPE_CHECKING:
from .manager import AnyAddon
_LOGGER: logging.Logger = logging.getLogger(__name__)
class AddonBuild(FileConfiguration, CoreSysAttributes):
"""Handle build options for add-ons."""
@@ -114,7 +106,7 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
return self.addon.path_location.joinpath(f"Dockerfile.{self.arch}")
return self.addon.path_location.joinpath("Dockerfile")
async def is_valid(self) -> None:
async def is_valid(self) -> bool:
"""Return true if the build env is valid."""
def build_is_valid() -> bool:
@@ -126,17 +118,9 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
)
try:
if not await self.sys_run_in_executor(build_is_valid):
raise AddonBuildDockerfileMissingError(
_LOGGER.error, addon=self.addon.slug
)
return await self.sys_run_in_executor(build_is_valid)
except HassioArchNotFound:
raise AddonBuildArchitectureNotSupportedError(
_LOGGER.error,
addon=self.addon.slug,
addon_arch_list=self.addon.supported_arch,
system_arch_list=self.sys_arch.supported,
) from None
return False
def get_docker_args(
self, version: AwesomeVersion, image_tag: str

View File

@@ -100,8 +100,6 @@ from ..const import (
from ..coresys import CoreSysAttributes
from ..docker.stats import DockerStats
from ..exceptions import (
AddonBootConfigCannotChangeError,
AddonConfigurationInvalidError,
APIAddonNotInstalled,
APIError,
APIForbidden,
@@ -127,7 +125,6 @@ SCHEMA_OPTIONS = vol.Schema(
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(str),
vol.Optional(ATTR_INGRESS_PANEL): vol.Boolean(),
vol.Optional(ATTR_WATCHDOG): vol.Boolean(),
vol.Optional(ATTR_OPTIONS): vol.Maybe(dict),
}
)
@@ -303,20 +300,19 @@ class APIAddons(CoreSysAttributes):
# Update secrets for validation
await self.sys_homeassistant.secrets.reload()
# Extend schema with add-on specific validation
addon_schema = SCHEMA_OPTIONS.extend(
{vol.Optional(ATTR_OPTIONS): vol.Maybe(addon.schema)}
)
# Validate/Process Body
body = await api_validate(SCHEMA_OPTIONS, request)
body = await api_validate(addon_schema, request)
if ATTR_OPTIONS in body:
try:
addon.options = addon.schema(body[ATTR_OPTIONS])
except vol.Invalid as ex:
raise AddonConfigurationInvalidError(
addon=addon.slug,
validation_error=humanize_error(body[ATTR_OPTIONS], ex),
) from None
addon.options = body[ATTR_OPTIONS]
if ATTR_BOOT in body:
if addon.boot_config == AddonBootConfig.MANUAL_ONLY:
raise AddonBootConfigCannotChangeError(
addon=addon.slug, boot_config=addon.boot_config.value
raise APIError(
f"Addon {addon.slug} boot option is set to {addon.boot_config} so it cannot be changed"
)
addon.boot = body[ATTR_BOOT]
if ATTR_AUTO_UPDATE in body:

View File

@@ -53,7 +53,7 @@ from ..const import (
REQUEST_FROM,
)
from ..coresys import CoreSysAttributes
from ..exceptions import APIError, APIForbidden, APINotFound, StoreAddonNotFoundError
from ..exceptions import APIError, APIForbidden, APINotFound
from ..store.addon import AddonStore
from ..store.repository import Repository
from ..store.validate import validate_repository
@@ -104,7 +104,7 @@ class APIStore(CoreSysAttributes):
addon_slug: str = request.match_info["addon"]
if not (addon := self.sys_addons.get(addon_slug)):
raise StoreAddonNotFoundError(addon=addon_slug)
raise APINotFound(f"Addon {addon_slug} does not exist")
if installed and not addon.is_installed:
raise APIError(f"Addon {addon_slug} is not installed")
@@ -112,7 +112,7 @@ class APIStore(CoreSysAttributes):
if not installed and addon.is_installed:
addon = cast(Addon, addon)
if not addon.addon_store:
raise StoreAddonNotFoundError(addon=addon_slug)
raise APINotFound(f"Addon {addon_slug} does not exist in the store")
return addon.addon_store
return addon

View File

@@ -9,10 +9,8 @@ from .addons.addon import Addon
from .const import ATTR_PASSWORD, ATTR_TYPE, ATTR_USERNAME, FILE_HASSIO_AUTH
from .coresys import CoreSys, CoreSysAttributes
from .exceptions import (
AuthHomeAssistantAPIValidationError,
AuthInvalidNoneValueError,
AuthError,
AuthListUsersError,
AuthListUsersNoneResponseError,
AuthPasswordResetError,
HomeAssistantAPIError,
HomeAssistantWSError,
@@ -85,8 +83,10 @@ class Auth(FileConfiguration, CoreSysAttributes):
self, addon: Addon, username: str | None, password: str | None
) -> bool:
"""Check username login."""
if username is None or password is None:
raise AuthInvalidNoneValueError(_LOGGER.error)
if password is None:
raise AuthError("None as password is not supported!", _LOGGER.error)
if username is None:
raise AuthError("None as username is not supported!", _LOGGER.error)
_LOGGER.info("Auth request from '%s' for '%s'", addon.slug, username)
@@ -137,7 +137,7 @@ class Auth(FileConfiguration, CoreSysAttributes):
finally:
self._running.pop(username, None)
raise AuthHomeAssistantAPIValidationError()
raise AuthError()
async def change_password(self, username: str, password: str) -> None:
"""Change user password login."""
@@ -155,7 +155,7 @@ class Auth(FileConfiguration, CoreSysAttributes):
except HomeAssistantAPIError as err:
_LOGGER.error("Can't request password reset on Home Assistant: %s", err)
raise AuthPasswordResetError(user=username)
raise AuthPasswordResetError()
async def list_users(self) -> list[dict[str, Any]]:
"""List users on the Home Assistant instance."""
@@ -166,12 +166,15 @@ class Auth(FileConfiguration, CoreSysAttributes):
{ATTR_TYPE: "config/auth/list"}
)
except HomeAssistantWSError as err:
_LOGGER.error("Can't request listing users on Home Assistant: %s", err)
raise AuthListUsersError() from err
raise AuthListUsersError(
f"Can't request listing users on Home Assistant: {err}", _LOGGER.error
) from err
if users is not None:
return users
raise AuthListUsersNoneResponseError(_LOGGER.error)
raise AuthListUsersError(
"Can't request listing users on Home Assistant!", _LOGGER.error
)
@staticmethod
def _rehash(value: str, salt2: str = "") -> str:

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
from collections.abc import Awaitable
from contextlib import suppress
from ipaddress import IPv4Address
import logging
@@ -33,7 +32,6 @@ from ..coresys import CoreSys
from ..exceptions import (
CoreDNSError,
DBusError,
DockerBuildError,
DockerError,
DockerJobError,
DockerNotFound,
@@ -681,8 +679,9 @@ class DockerAddon(DockerInterface):
async def _build(self, version: AwesomeVersion, image: str | None = None) -> None:
"""Build a Docker container."""
build_env = await AddonBuild(self.coresys, self.addon).load_config()
# Check if the build environment is valid, raises if not
await build_env.is_valid()
if not await build_env.is_valid():
_LOGGER.error("Invalid build environment, can't build this add-on!")
raise DockerError()
_LOGGER.info("Starting build for %s:%s", self.image, version)
@@ -731,9 +730,8 @@ class DockerAddon(DockerInterface):
self._meta = docker_image.attrs
except (docker.errors.DockerException, requests.RequestException) as err:
raise DockerBuildError(
f"Can't build {self.image}:{version}: {err!s}", _LOGGER.error
) from err
_LOGGER.error("Can't build %s:%s: %s", self.image, version, err)
raise DockerError() from err
_LOGGER.info("Build %s:%s done", self.image, version)
@@ -790,9 +788,12 @@ class DockerAddon(DockerInterface):
on_condition=DockerJobError,
concurrency=JobConcurrency.GROUP_REJECT,
)
def write_stdin(self, data: bytes) -> Awaitable[None]:
async def write_stdin(self, data: bytes) -> None:
"""Write to add-on stdin."""
return self.sys_run_in_executor(self._write_stdin, data)
if not await self.is_running():
raise DockerError()
await self.sys_run_in_executor(self._write_stdin, data)
def _write_stdin(self, data: bytes) -> None:
"""Write to add-on stdin.

View File

@@ -309,18 +309,30 @@ class DockerInterface(JobGroup, ABC):
stage in {PullImageLayerStage.DOWNLOADING, PullImageLayerStage.EXTRACTING}
and reference.progress_detail
):
# For containerd snapshotter, extracting phase has total=None
# In that case, use the download_total from the downloading phase
current_extra: dict[str, Any] = job.extra if job.extra else {}
if (
stage == PullImageLayerStage.DOWNLOADING
and reference.progress_detail.total
):
# Store download total for use in extraction phase with containerd snapshotter
current_extra["download_total"] = reference.progress_detail.total
job.update(
progress=progress,
stage=stage.status,
extra={
"current": reference.progress_detail.current,
"total": reference.progress_detail.total,
"total": reference.progress_detail.total
or current_extra.get("download_total"),
"download_total": current_extra.get("download_total"),
},
)
else:
# If we reach DOWNLOAD_COMPLETE without ever having set extra (small layers that skip
# the downloading phase), set a minimal extra so aggregate progress calculation can proceed
extra = job.extra
extra: dict[str, Any] | None = job.extra
if stage == PullImageLayerStage.DOWNLOAD_COMPLETE and not job.extra:
extra = {"current": 1, "total": 1}
@@ -346,7 +358,11 @@ class DockerInterface(JobGroup, ABC):
for job in layer_jobs:
if not job.extra:
return
total += job.extra["total"]
# Use download_total if available (for containerd snapshotter), otherwise use total
layer_total = job.extra.get("download_total") or job.extra.get("total")
if layer_total is None:
return
total += layer_total
install_job.extra = {"total": total}
else:
total = install_job.extra["total"]
@@ -357,7 +373,11 @@ class DockerInterface(JobGroup, ABC):
for job in layer_jobs:
if not job.extra:
return
progress += job.progress * (job.extra["total"] / total)
# Use download_total if available (for containerd snapshotter), otherwise use total
layer_total = job.extra.get("download_total") or job.extra.get("total")
if layer_total is None:
return
progress += job.progress * (layer_total / total)
job_stage = PullImageLayerStage.from_status(cast(str, job.stage))
if job_stage < PullImageLayerStage.EXTRACTING:
@@ -466,34 +486,35 @@ class DockerInterface(JobGroup, ABC):
return True
return False
async def _get_container(self) -> Container | None:
"""Get docker container, returns None if not found."""
async def is_running(self) -> bool:
"""Return True if Docker is running."""
try:
return await self.sys_run_in_executor(
docker_container = await self.sys_run_in_executor(
self.sys_docker.containers.get, self.name
)
except docker.errors.NotFound:
return None
return False
except docker.errors.DockerException as err:
raise DockerAPIError(
f"Docker API error occurred while getting container information: {err!s}"
) from err
raise DockerAPIError() from err
except requests.RequestException as err:
raise DockerRequestError(
f"Error communicating with Docker to get container information: {err!s}"
) from err
raise DockerRequestError() from err
async def is_running(self) -> bool:
"""Return True if Docker is running."""
if docker_container := await self._get_container():
return docker_container.status == "running"
return False
return docker_container.status == "running"
async def current_state(self) -> ContainerState:
"""Return current state of container."""
if docker_container := await self._get_container():
return _container_state_from_model(docker_container)
return ContainerState.UNKNOWN
try:
docker_container = await self.sys_run_in_executor(
self.sys_docker.containers.get, self.name
)
except docker.errors.NotFound:
return ContainerState.UNKNOWN
except docker.errors.DockerException as err:
raise DockerAPIError() from err
except requests.RequestException as err:
raise DockerRequestError() from err
return _container_state_from_model(docker_container)
@Job(name="docker_interface_attach", concurrency=JobConcurrency.GROUP_QUEUE)
async def attach(
@@ -528,9 +549,7 @@ class DockerInterface(JobGroup, ABC):
# Successful?
if not self._meta:
raise DockerError(
f"Could not get metadata on container or image for {self.name}"
)
raise DockerError()
_LOGGER.info("Attaching to %s with version %s", self.image, self.version)
@Job(
@@ -732,8 +751,14 @@ class DockerInterface(JobGroup, ABC):
async def is_failed(self) -> bool:
"""Return True if Docker is failing state."""
if not (docker_container := await self._get_container()):
try:
docker_container = await self.sys_run_in_executor(
self.sys_docker.containers.get, self.name
)
except docker.errors.NotFound:
return False
except (docker.errors.DockerException, requests.RequestException) as err:
raise DockerError() from err
# container is not running
if docker_container.status != "exited":

View File

@@ -566,10 +566,7 @@ class DockerAPI(CoreSysAttributes):
except NotFound:
return False
except (DockerException, requests.RequestException) as err:
raise DockerError(
f"Could not get container {name} or image {image}:{version} to check state: {err!s}",
_LOGGER.error,
) from err
raise DockerError() from err
# Check the image is correct and state is good
return (
@@ -585,13 +582,9 @@ class DockerAPI(CoreSysAttributes):
try:
docker_container: Container = self.containers.get(name)
except NotFound:
# Generally suppressed so we don't log this
raise DockerNotFound() from None
except (DockerException, requests.RequestException) as err:
raise DockerError(
f"Could not get container {name} for stopping: {err!s}",
_LOGGER.error,
) from err
raise DockerError() from err
if docker_container.status == "running":
_LOGGER.info("Stopping %s application", name)
@@ -631,13 +624,9 @@ class DockerAPI(CoreSysAttributes):
try:
container: Container = self.containers.get(name)
except NotFound:
raise DockerNotFound(
f"Container {name} not found for restarting", _LOGGER.warning
) from None
raise DockerNotFound() from None
except (DockerException, requests.RequestException) as err:
raise DockerError(
f"Could not get container {name} for restarting: {err!s}", _LOGGER.error
) from err
raise DockerError() from err
_LOGGER.info("Restarting %s", name)
try:
@@ -650,13 +639,9 @@ class DockerAPI(CoreSysAttributes):
try:
docker_container: Container = self.containers.get(name)
except NotFound:
raise DockerNotFound(
f"Container {name} not found for logs", _LOGGER.warning
) from None
raise DockerNotFound() from None
except (DockerException, requests.RequestException) as err:
raise DockerError(
f"Could not get container {name} for logs: {err!s}", _LOGGER.error
) from err
raise DockerError() from err
try:
return docker_container.logs(tail=tail, stdout=True, stderr=True)
@@ -670,13 +655,9 @@ class DockerAPI(CoreSysAttributes):
try:
docker_container: Container = self.containers.get(name)
except NotFound:
raise DockerNotFound(
f"Container {name} not found for stats", _LOGGER.warning
) from None
raise DockerNotFound() from None
except (DockerException, requests.RequestException) as err:
raise DockerError(
f"Could not inspect container '{name}': {err!s}", _LOGGER.error
) from err
raise DockerError() from err
# container is not running
if docker_container.status != "running":
@@ -694,21 +675,15 @@ class DockerAPI(CoreSysAttributes):
try:
docker_container: Container = self.containers.get(name)
except NotFound:
raise DockerNotFound(
f"Container {name} not found for running command", _LOGGER.warning
) from None
raise DockerNotFound() from None
except (DockerException, requests.RequestException) as err:
raise DockerError(
f"Can't get container {name} to run command: {err!s}"
) from err
raise DockerError() from err
# Execute
try:
code, output = docker_container.exec_run(command)
except (DockerException, requests.RequestException) as err:
raise DockerError(
f"Can't run command in container {name}: {err!s}"
) from err
raise DockerError() from err
return CommandReturn(code, output)

View File

@@ -3,23 +3,23 @@
from collections.abc import Callable
from typing import Any
MESSAGE_CHECK_SUPERVISOR_LOGS = (
"Check supervisor logs for details (check with '{logs_command}')"
)
EXTRA_FIELDS_LOGS_COMMAND = {"logs_command": "ha supervisor logs"}
class HassioError(Exception):
"""Root exception."""
error_key: str | None = None
message_template: str | None = None
extra_fields: dict[str, Any] | None = None
def __init__(
self, message: str | None = None, logger: Callable[..., None] | None = None
self,
message: str | None = None,
logger: Callable[..., None] | None = None,
*,
extra_fields: dict[str, Any] | None = None,
) -> None:
"""Raise & log."""
self.extra_fields = extra_fields or {}
if not message and self.message_template:
message = (
self.message_template.format(**self.extra_fields)
@@ -41,82 +41,6 @@ class HassioNotSupportedError(HassioError):
"""Function is not supported."""
# API
class APIError(HassioError, RuntimeError):
"""API errors."""
status = 400
def __init__(
self,
message: str | None = None,
logger: Callable[..., None] | None = None,
*,
job_id: str | None = None,
) -> None:
"""Raise & log, optionally with job."""
super().__init__(message, logger)
self.job_id = job_id
class APIUnauthorized(APIError):
"""API unauthorized error."""
status = 401
class APIForbidden(APIError):
"""API forbidden error."""
status = 403
class APINotFound(APIError):
"""API not found error."""
status = 404
class APIGone(APIError):
"""API is no longer available."""
status = 410
class APIInternalServerError(APIError):
"""API internal server error."""
status = 500
class APIAddonNotInstalled(APIError):
"""Not installed addon requested at addons API."""
class APIDBMigrationInProgress(APIError):
"""Service is unavailable due to an offline DB migration is in progress."""
status = 503
class APIUnknownSupervisorError(APIError):
"""Unknown error occurred within supervisor. Adds supervisor check logs rider to mesage template."""
status = 500
def __init__(
self, logger: Callable[..., None] | None = None, *, job_id: str | None = None
) -> None:
"""Initialize exception."""
self.message_template = (
f"{self.message_template}. {MESSAGE_CHECK_SUPERVISOR_LOGS}"
)
self.extra_fields = (self.extra_fields or {}) | EXTRA_FIELDS_LOGS_COMMAND
super().__init__(None, logger, job_id=job_id)
# JobManager
@@ -198,20 +122,6 @@ class SupervisorAppArmorError(SupervisorError):
"""Supervisor AppArmor error."""
class SupervisorStatsError(SupervisorError, APIInternalServerError):
"""Raise on issue getting stats for Supervisor container."""
error_key = "supervisor_stats_error"
message_template = (
f"Can't get stats for Supervisor container. {MESSAGE_CHECK_SUPERVISOR_LOGS}"
)
extra_fields = EXTRA_FIELDS_LOGS_COMMAND.copy()
def __init__(self, logger: Callable[..., None] | None = None) -> None:
"""Initialize exception."""
super().__init__(None, logger)
class SupervisorJobError(SupervisorError, JobException):
"""Raise on job errors."""
@@ -340,96 +250,6 @@ class AddonConfigurationError(AddonsError):
"""Error with add-on configuration."""
class AddonConfigurationInvalidError(AddonConfigurationError, APIError):
"""Raise if invalid configuration provided for addon."""
error_key = "addon_configuration_invalid_error"
message_template = "Add-on {addon} has invalid options: {validation_error}"
def __init__(
self,
logger: Callable[..., None] | None = None,
*,
addon: str,
validation_error: str,
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon, "validation_error": validation_error}
super().__init__(None, logger)
class AddonBackupMetadataInvalidError(AddonsError, APIError):
"""Raise if invalid metadata file provided for addon in backup."""
error_key = "addon_backup_metadata_invalid_error"
message_template = (
"Metadata file for add-on {addon} in backup is invalid: {validation_error}"
)
def __init__(
self,
logger: Callable[..., None] | None = None,
*,
addon: str,
validation_error: str,
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon, "validation_error": validation_error}
super().__init__(None, logger)
class AddonBootConfigCannotChangeError(AddonsError, APIError):
"""Raise if user attempts to change addon boot config when it can't be changed."""
error_key = "addon_boot_config_cannot_change_error"
message_template = (
"Addon {addon} boot option is set to {boot_config} so it cannot be changed"
)
def __init__(
self, logger: Callable[..., None] | None = None, *, addon: str, boot_config: str
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon, "boot_config": boot_config}
super().__init__(None, logger)
class AddonNotRunningError(AddonsError, APIError):
"""Raise when an addon is not running."""
error_key = "addon_not_running_error"
message_template = "Add-on {addon} is not running"
def __init__(
self, logger: Callable[..., None] | None = None, *, addon: str
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon}
super().__init__(None, logger)
class AddonPrePostBackupCommandReturnedError(AddonsError, APIError):
"""Raise when addon's pre/post backup command returns an error."""
error_key = "addon_pre_post_backup_command_returned_error"
message_template = (
"Pre-/Post backup command for add-on {addon} returned error code: "
"{exit_code}. Please report this to the addon developer. Enable debug "
"logging to capture complete command output using {debug_logging_command}"
)
def __init__(
self, logger: Callable[..., None] | None = None, *, addon: str, exit_code: int
) -> None:
"""Initialize exception."""
self.extra_fields = {
"addon": addon,
"exit_code": exit_code,
"debug_logging_command": "ha supervisor options --logging debug",
}
super().__init__(None, logger)
class AddonNotSupportedError(HassioNotSupportedError):
"""Addon doesn't support a function."""
@@ -448,8 +268,11 @@ class AddonNotSupportedArchitectureError(AddonNotSupportedError):
architectures: list[str],
) -> None:
"""Initialize exception."""
self.extra_fields = {"slug": slug, "architectures": ", ".join(architectures)}
super().__init__(None, logger)
super().__init__(
None,
logger,
extra_fields={"slug": slug, "architectures": ", ".join(architectures)},
)
class AddonNotSupportedMachineTypeError(AddonNotSupportedError):
@@ -466,8 +289,11 @@ class AddonNotSupportedMachineTypeError(AddonNotSupportedError):
machine_types: list[str],
) -> None:
"""Initialize exception."""
self.extra_fields = {"slug": slug, "machine_types": ", ".join(machine_types)}
super().__init__(None, logger)
super().__init__(
None,
logger,
extra_fields={"slug": slug, "machine_types": ", ".join(machine_types)},
)
class AddonNotSupportedHomeAssistantVersionError(AddonNotSupportedError):
@@ -484,307 +310,11 @@ class AddonNotSupportedHomeAssistantVersionError(AddonNotSupportedError):
version: str,
) -> None:
"""Initialize exception."""
self.extra_fields = {"slug": slug, "version": version}
super().__init__(None, logger)
class AddonNotSupportedWriteStdinError(AddonNotSupportedError, APIError):
"""Addon does not support writing to stdin."""
error_key = "addon_not_supported_write_stdin_error"
message_template = "Add-on {addon} does not support writing to stdin"
def __init__(
self, logger: Callable[..., None] | None = None, *, addon: str
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon}
super().__init__(None, logger)
class AddonBuildDockerfileMissingError(AddonNotSupportedError, APIError):
"""Raise when addon build invalid because dockerfile is missing."""
error_key = "addon_build_dockerfile_missing_error"
message_template = (
"Cannot build addon '{addon}' because dockerfile is missing. A repair "
"using '{repair_command}' will fix this if the cause is data "
"corruption. Otherwise please report this to the addon developer."
)
def __init__(
self, logger: Callable[..., None] | None = None, *, addon: str
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon, "repair_command": "ha supervisor repair"}
super().__init__(None, logger)
class AddonBuildArchitectureNotSupportedError(AddonNotSupportedError, APIError):
"""Raise when addon cannot be built on system because it doesn't support its architecture."""
error_key = "addon_build_architecture_not_supported_error"
message_template = (
"Cannot build addon '{addon}' because its supported architectures "
"({addon_arches}) do not match the system supported architectures ({system_arches})"
)
def __init__(
self,
logger: Callable[..., None] | None = None,
*,
addon: str,
addon_arch_list: list[str],
system_arch_list: list[str],
) -> None:
"""Initialize exception."""
self.extra_fields = {
"addon": addon,
"addon_arches": ", ".join(addon_arch_list),
"system_arches": ", ".join(system_arch_list),
}
super().__init__(None, logger)
# pylint: disable-next=too-many-ancestors
class AddonConfigurationFileUnknownError(
AddonConfigurationError, APIUnknownSupervisorError
):
"""Raise when unknown error occurs trying to read/write addon configuration file."""
error_key = "addon_configuration_file_unknown_error"
message_template = (
"An unknown error occurred reading/writing configuration file for {addon}"
)
def __init__(
self, logger: Callable[..., None] | None = None, *, addon: str
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon}
super().__init__(logger)
class AddonBuildImageUnknownError(AddonsError, APIUnknownSupervisorError):
"""Raise when an unknown error occurs during image build."""
error_key = "addon_build_image_unknown_error"
message_template = "An unknown error occurred during build of image for {addon}"
def __init__(
self, logger: Callable[..., None] | None = None, *, addon: str
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon}
super().__init__(logger)
class AddonInstallImageUnknownError(AddonsError, APIUnknownSupervisorError):
"""Raise when an unknown error occurs during image install."""
error_key = "addon_install_image_unknown_error"
message_template = "An unknown error occurred during install of image for {addon}"
def __init__(
self, logger: Callable[..., None] | None = None, *, addon: str
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon}
super().__init__(logger)
class AddonRemoveImageUnknownError(AddonsError, APIUnknownSupervisorError):
"""Raise when an unknown error occurs while removing an image."""
error_key = "addon_remove_image_unknown_error"
message_template = "An unknown error occurred while removing image for {addon}"
def __init__(
self, logger: Callable[..., None] | None = None, *, addon: str
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon}
super().__init__(logger)
class AddonContainerStartUnknownError(AddonsError, APIUnknownSupervisorError):
"""Raise when an unknown error occurs while starting a container."""
error_key = "addon_container_start_unknown_error"
message_template = "An unknown error occurred while starting container for {addon}"
def __init__(
self, logger: Callable[..., None] | None = None, *, addon: str
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon}
super().__init__(logger)
class AddonContainerStopUnknownError(AddonsError, APIUnknownSupervisorError):
"""Raise when an unknown error occurs while stopping a container."""
error_key = "addon_container_stop_unknown_error"
message_template = "An unknown error occurred while stopping container for {addon}"
def __init__(
self, logger: Callable[..., None] | None = None, *, addon: str
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon}
super().__init__(logger)
class AddonContainerStatsUnknownError(AddonsError, APIUnknownSupervisorError):
"""Raise when an unknown error occurs while getting stats of a container."""
error_key = "addon_container_stats_unknown_error"
message_template = (
"An unknown error occurred while getting stats of container for {addon}"
)
def __init__(
self, logger: Callable[..., None] | None = None, *, addon: str
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon}
super().__init__(logger)
class AddonContainerWriteStdinUnknownError(AddonsError, APIUnknownSupervisorError):
"""Raise when an unknown error occurs while writing to stdin of a container."""
error_key = "addon_container_write_stdin_unknown_error"
message_template = (
"An unknown error occurred while writing to stdin of container for {addon}"
)
def __init__(
self, logger: Callable[..., None] | None = None, *, addon: str
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon}
super().__init__(logger)
class AddonContainerRunCommandUnknownError(AddonsError, APIUnknownSupervisorError):
"""Raise when an unknown error occurs while running command inside of a container."""
error_key = "addon_container_run_command_unknown_error"
message_template = "An unknown error occurred while running a command inside of container for {addon}"
def __init__(
self, logger: Callable[..., None] | None = None, *, addon: str
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon}
super().__init__(logger)
class AddonCreateBackupFileUnknownError(AddonsError, APIUnknownSupervisorError):
"""Raise when an unknown error occurs while making the backup file for an addon."""
error_key = "addon_create_backup_file_unknown_error"
message_template = (
"An unknown error occurred while creating the backup file for {addon}"
)
def __init__(
self, logger: Callable[..., None] | None = None, *, addon: str
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon}
super().__init__(logger)
class AddonCreateBackupMetadataFileUnknownError(AddonsError, APIUnknownSupervisorError):
"""Raise when an unknown error occurs while making the metadata file for an addon backup."""
error_key = "addon_create_backup_metadata_file_unknown_error"
message_template = "An unknown error occurred while creating the metadata file for backup of {addon}"
def __init__(
self, logger: Callable[..., None] | None = None, *, addon: str
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon}
super().__init__(logger)
class AddonBackupAppArmorProfileUnknownError(AddonsError, APIUnknownSupervisorError):
"""Raise when an unknown error occurs while backing up the AppArmor profile of an addon."""
error_key = "addon_backup_app_armor_profile_unknown_error"
message_template = (
"An unknown error occurred while backing up the AppArmor profile of {addon}"
)
def __init__(
self, logger: Callable[..., None] | None = None, *, addon: str
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon}
super().__init__(logger)
class AddonBackupExportImageUnknownError(AddonsError, APIUnknownSupervisorError):
"""Raise when an unknown error occurs while exporting image for an addon backup."""
error_key = "addon_backup_export_image_unknown_error"
message_template = (
"An unknown error occurred while exporting image to back up {addon}"
)
def __init__(
self, logger: Callable[..., None] | None = None, *, addon: str
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon}
super().__init__(logger)
class AddonExtractBackupFileUnknownError(AddonsError, APIUnknownSupervisorError):
"""Raise when an unknown error occurs while extracting backup file for an addon."""
error_key = "addon_extract_backup_file_unknown_error"
message_template = (
"An unknown error occurred while extracting the backup file for {addon}"
)
def __init__(
self, logger: Callable[..., None] | None = None, *, addon: str
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon}
super().__init__(logger)
class AddonRestoreBackupDataUnknownError(AddonsError, APIUnknownSupervisorError):
"""Raise when unknown error occurs while restoring data/config for addon from backup."""
error_key = "addon_restore_backup_data_unknown_error"
message_template = "An unknown error occurred while restoring data and config for {addon} from backup"
def __init__(
self, logger: Callable[..., None] | None = None, *, addon: str
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon}
super().__init__(logger)
class AddonRestoreAppArmorProfileUnknownError(AddonsError, APIUnknownSupervisorError):
"""Raise when unknown error occurs while restoring AppArmor profile for addon from backup."""
error_key = "addon_restore_app_armor_profile_unknown_error"
message_template = "An unknown error occurred while restoring AppArmor profile for {addon} from backup"
def __init__(
self, logger: Callable[..., None] | None = None, *, addon: str
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon}
super().__init__(logger)
super().__init__(
None,
logger,
extra_fields={"slug": slug, "version": version},
)
class AddonsJobError(AddonsError, JobException):
@@ -816,59 +346,13 @@ class AuthError(HassioError):
"""Auth errors."""
# This one uses the check logs rider even though its not a 500 error because it
# is bad practice to return error specifics from a password reset API.
class AuthPasswordResetError(AuthError, APIError):
class AuthPasswordResetError(HassioError):
"""Auth error if password reset failed."""
error_key = "auth_password_reset_error"
message_template = (
f"Unable to reset password for '{{user}}'. {MESSAGE_CHECK_SUPERVISOR_LOGS}"
)
def __init__(self, logger: Callable[..., None] | None = None, *, user: str) -> None:
"""Initialize exception."""
self.extra_fields = {"user": user} | EXTRA_FIELDS_LOGS_COMMAND
super().__init__(None, logger)
class AuthListUsersError(AuthError, APIUnknownSupervisorError):
class AuthListUsersError(HassioError):
"""Auth error if listing users failed."""
error_key = "auth_list_users_error"
message_template = "Can't request listing users on Home Assistant"
class AuthListUsersNoneResponseError(AuthError, APIInternalServerError):
"""Auth error if listing users returned invalid None response."""
error_key = "auth_list_users_none_response_error"
message_template = "Home Assistant returned invalid response of `{none}` instead of a list of users. Check Home Assistant logs for details (check with `{logs_command}`)"
extra_fields = {"none": "None", "logs_command": "ha core logs"}
def __init__(self, logger: Callable[..., None] | None = None) -> None:
"""Initialize exception."""
super().__init__(None, logger)
class AuthInvalidNoneValueError(AuthError, APIUnauthorized):
"""Auth error if None provided as username or password."""
error_key = "auth_invalid_none_value_error"
message_template = "{none} as username or password is not supported"
extra_fields = {"none": "None"}
def __init__(self, logger: Callable[..., None] | None = None) -> None:
"""Initialize exception."""
super().__init__(None, logger)
class AuthHomeAssistantAPIValidationError(AuthError, APIUnknownSupervisorError):
"""Error encountered trying to validate auth details via Home Assistant API."""
error_key = "auth_home_assistant_api_validation_error"
message_template = "Unable to validate authentication details with Home Assistant"
# Host
@@ -901,6 +385,60 @@ class HostLogError(HostError):
"""Internal error with host log."""
# API
class APIError(HassioError, RuntimeError):
"""API errors."""
status = 400
def __init__(
self,
message: str | None = None,
logger: Callable[..., None] | None = None,
*,
job_id: str | None = None,
error: HassioError | None = None,
) -> None:
"""Raise & log, optionally with job."""
# Allow these to be set from another error here since APIErrors essentially wrap others to add a status
self.error_key = error.error_key if error else None
self.message_template = error.message_template if error else None
super().__init__(
message, logger, extra_fields=error.extra_fields if error else None
)
self.job_id = job_id
class APIForbidden(APIError):
"""API forbidden error."""
status = 403
class APINotFound(APIError):
"""API not found error."""
status = 404
class APIGone(APIError):
"""API is no longer available."""
status = 410
class APIAddonNotInstalled(APIError):
"""Not installed addon requested at addons API."""
class APIDBMigrationInProgress(APIError):
"""Service is unavailable due to an offline DB migration is in progress."""
status = 503
# Service / Discovery
@@ -1078,10 +616,6 @@ class DockerError(HassioError):
"""Docker API/Transport errors."""
class DockerBuildError(DockerError):
"""Docker error during build."""
class DockerAPIError(DockerError):
"""Docker API error."""
@@ -1178,20 +712,6 @@ class StoreNotFound(StoreError):
"""Raise if slug is not known."""
class StoreAddonNotFoundError(StoreError, APINotFound):
"""Raise if a requested addon is not in the store."""
error_key = "store_addon_not_found_error"
message_template = "Addon {addon} does not exist in the store"
def __init__(
self, logger: Callable[..., None] | None = None, *, addon: str
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon}
super().__init__(None, logger)
class StoreJobError(StoreError, JobException):
"""Raise on job error with git."""

View File

@@ -28,8 +28,8 @@ from .exceptions import (
DockerError,
HostAppArmorError,
SupervisorAppArmorError,
SupervisorError,
SupervisorJobError,
SupervisorStatsError,
SupervisorUpdateError,
)
from .jobs.const import JobCondition, JobThrottle
@@ -261,7 +261,7 @@ class Supervisor(CoreSysAttributes):
try:
return await self.instance.stats()
except DockerError as err:
raise SupervisorStatsError() from err
raise SupervisorError() from err
async def repair(self):
"""Repair local Supervisor data."""

View File

@@ -938,7 +938,7 @@ async def test_addon_load_succeeds_with_docker_errors(
coresys.docker.images.get.side_effect = ImageNotFound("missing")
caplog.clear()
await install_addon_ssh.load()
assert "Cannot build addon 'local_ssh' because dockerfile is missing" in caplog.text
assert "Invalid build environment" in caplog.text
# Image build failure
coresys.docker.images.build.side_effect = DockerException()

View File

@@ -3,12 +3,10 @@
from unittest.mock import PropertyMock, patch
from awesomeversion import AwesomeVersion
import pytest
from supervisor.addons.addon import Addon
from supervisor.addons.build import AddonBuild
from supervisor.coresys import CoreSys
from supervisor.exceptions import AddonBuildDockerfileMissingError
from tests.common import is_in_list
@@ -104,11 +102,11 @@ async def test_build_valid(coresys: CoreSys, install_addon_ssh: Addon):
type(coresys.arch), "default", new=PropertyMock(return_value="aarch64")
),
):
assert (await build.is_valid()) is None
assert await build.is_valid()
async def test_build_invalid(coresys: CoreSys, install_addon_ssh: Addon):
"""Test build not supported because Dockerfile missing for specified architecture."""
"""Test platform set in docker args."""
build = await AddonBuild(coresys, install_addon_ssh).load_config()
with (
patch.object(
@@ -117,6 +115,5 @@ async def test_build_invalid(coresys: CoreSys, install_addon_ssh: Addon):
patch.object(
type(coresys.arch), "default", new=PropertyMock(return_value="amd64")
),
pytest.raises(AddonBuildDockerfileMissingError),
):
await build.is_valid()
assert not await build.is_valid()

View File

@@ -482,11 +482,6 @@ async def test_addon_options_boot_mode_manual_only_invalid(
body["message"]
== "Addon local_example boot option is set to manual_only so it cannot be changed"
)
assert body["error_key"] == "addon_boot_config_cannot_change_error"
assert body["extra_fields"] == {
"addon": "local_example",
"boot_config": "manual_only",
}
async def get_message(resp: ClientResponse, json_expected: bool) -> str:
@@ -555,94 +550,3 @@ async def test_addon_not_installed(
resp = await api_client.request(method, url)
assert resp.status == 400
assert await get_message(resp, json_expected) == "Addon is not installed"
async def test_addon_set_options(api_client: TestClient, install_addon_example: Addon):
"""Test setting options for an addon."""
resp = await api_client.post(
"/addons/local_example/options", json={"options": {"message": "test"}}
)
assert resp.status == 200
assert install_addon_example.options == {"message": "test"}
async def test_addon_set_options_error(
api_client: TestClient, install_addon_example: Addon
):
"""Test setting options for an addon."""
resp = await api_client.post(
"/addons/local_example/options", json={"options": {"message": True}}
)
assert resp.status == 400
body = await resp.json()
assert (
body["message"]
== "Add-on local_example has invalid options: not a valid value. Got {'message': True}"
)
assert body["error_key"] == "addon_configuration_invalid_error"
assert body["extra_fields"] == {
"addon": "local_example",
"validation_error": "not a valid value. Got {'message': True}",
}
async def test_addon_start_options_error(
api_client: TestClient,
install_addon_example: Addon,
caplog: pytest.LogCaptureFixture,
):
"""Test error writing options when trying to start addon."""
install_addon_example.options = {"message": "hello"}
# Simulate OS error trying to write the file
with patch("supervisor.utils.json.atomic_write", side_effect=OSError("fail")):
resp = await api_client.post("/addons/local_example/start")
assert resp.status == 500
body = await resp.json()
assert (
body["message"]
== "An unknown error occurred reading/writing configuration file for local_example. Check supervisor logs for details (check with 'ha supervisor logs')"
)
assert body["error_key"] == "addon_configuration_file_unknown_error"
assert body["extra_fields"] == {
"addon": "local_example",
"logs_command": "ha supervisor logs",
}
assert "Add-on local_example can't write options" in caplog.text
# Simulate an update with a breaking change for options schema creating failure on start
caplog.clear()
install_addon_example.data["schema"] = {"message": "bool"}
resp = await api_client.post("/addons/local_example/start")
assert resp.status == 400
body = await resp.json()
assert (
body["message"]
== "Add-on local_example has invalid options: expected boolean. Got {'message': 'hello'}"
)
assert body["error_key"] == "addon_configuration_invalid_error"
assert body["extra_fields"] == {
"addon": "local_example",
"validation_error": "expected boolean. Got {'message': 'hello'}",
}
assert (
"Add-on local_example has invalid options: expected boolean. Got {'message': 'hello'}"
in caplog.text
)
@pytest.mark.parametrize(("method", "action"), [("get", "stats"), ("post", "stdin")])
async def test_addon_not_running_error(
api_client: TestClient, install_addon_example: Addon, method: str, action: str
):
"""Test addon not running error for endpoints that require that."""
with patch.object(
Addon, "with_stdin", return_value=PropertyMock(return_value=True)
):
resp = await api_client.request(method, f"/addons/local_example/{action}")
assert resp.status == 400
body = await resp.json()
assert body["message"] == "Add-on local_example is not running"
assert body["error_key"] == "addon_not_running_error"
assert body["extra_fields"] == {"addon": "local_example"}

View File

@@ -6,11 +6,9 @@ from unittest.mock import AsyncMock, MagicMock, patch
from aiohttp.hdrs import WWW_AUTHENTICATE
from aiohttp.test_utils import TestClient
import pytest
from securetar import Any
from supervisor.addons.addon import Addon
from supervisor.coresys import CoreSys
from supervisor.exceptions import HomeAssistantAPIError, HomeAssistantWSError
from tests.common import MockResponse
from tests.const import TEST_ADDON_SLUG
@@ -102,52 +100,6 @@ async def test_password_reset(
assert "Successful password reset for 'john'" in caplog.text
@pytest.mark.parametrize(
("post_mock", "expected_log"),
[
(
MagicMock(return_value=MockResponse(status=400)),
"The user 'john' is not registered",
),
(
MagicMock(side_effect=HomeAssistantAPIError("fail")),
"Can't request password reset on Home Assistant: fail",
),
],
)
async def test_failed_password_reset(
api_client: TestClient,
coresys: CoreSys,
caplog: pytest.LogCaptureFixture,
websession: MagicMock,
post_mock: MagicMock,
expected_log: str,
):
"""Test failed password reset."""
coresys.homeassistant.api.access_token = "abc123"
# pylint: disable-next=protected-access
coresys.homeassistant.api._access_token_expires = datetime.now(tz=UTC) + timedelta(
days=1
)
websession.post = post_mock
resp = await api_client.post(
"/auth/reset", json={"username": "john", "password": "doe"}
)
assert resp.status == 400
body = await resp.json()
assert (
body["message"]
== "Unable to reset password for 'john'. Check supervisor logs for details (check with 'ha supervisor logs')"
)
assert body["error_key"] == "auth_password_reset_error"
assert body["extra_fields"] == {
"user": "john",
"logs_command": "ha supervisor logs",
}
assert expected_log in caplog.text
async def test_list_users(
api_client: TestClient, coresys: CoreSys, ha_ws_client: AsyncMock
):
@@ -168,48 +120,6 @@ async def test_list_users(
]
@pytest.mark.parametrize(
("send_command_mock", "error_response", "expected_log"),
[
(
AsyncMock(return_value=None),
{
"result": "error",
"message": "Home Assistant returned invalid response of `None` instead of a list of users. Check Home Assistant logs for details (check with `ha core logs`)",
"error_key": "auth_list_users_none_response_error",
"extra_fields": {"none": "None", "logs_command": "ha core logs"},
},
"Home Assistant returned invalid response of `None` instead of a list of users. Check Home Assistant logs for details (check with `ha core logs`)",
),
(
AsyncMock(side_effect=HomeAssistantWSError("fail")),
{
"result": "error",
"message": "Can't request listing users on Home Assistant. Check supervisor logs for details (check with 'ha supervisor logs')",
"error_key": "auth_list_users_error",
"extra_fields": {"logs_command": "ha supervisor logs"},
},
"Can't request listing users on Home Assistant: fail",
),
],
)
async def test_list_users_failure(
api_client: TestClient,
ha_ws_client: AsyncMock,
caplog: pytest.LogCaptureFixture,
send_command_mock: AsyncMock,
error_response: dict[str, Any],
expected_log: str,
):
"""Test failure listing users via API."""
ha_ws_client.async_send_command = send_command_mock
resp = await api_client.get("/auth/list")
assert resp.status == 500
result = await resp.json()
assert result == error_response
assert expected_log in caplog.text
@pytest.mark.parametrize(
("field", "api_client"),
[("username", TEST_ADDON_SLUG), ("user", TEST_ADDON_SLUG)],

View File

@@ -323,10 +323,7 @@ async def test_store_addon_not_found(
"""Test store addon not found error."""
resp = await api_client.request(method, url)
assert resp.status == 404
assert (
await get_message(resp, json_expected)
== "Addon bad does not exist in the store"
)
assert await get_message(resp, json_expected) == "Addon bad does not exist"
@pytest.mark.parametrize(

View File

@@ -7,7 +7,6 @@ from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
from aiohttp.test_utils import TestClient
from awesomeversion import AwesomeVersion
from blockbuster import BlockingError
from docker.errors import DockerException
import pytest
from supervisor.const import CoreState
@@ -418,37 +417,3 @@ async def test_api_progress_updates_supervisor_update(
"done": True,
},
]
async def test_api_supervisor_stats(api_client: TestClient, coresys: CoreSys):
"""Test supervisor stats."""
coresys.docker.containers.get.return_value.status = "running"
coresys.docker.containers.get.return_value.stats.return_value = load_json_fixture(
"container_stats.json"
)
resp = await api_client.get("/supervisor/stats")
assert resp.status == 200
result = await resp.json()
assert result["data"]["cpu_percent"] == 90.0
assert result["data"]["memory_usage"] == 59700000
assert result["data"]["memory_limit"] == 4000000000
assert result["data"]["memory_percent"] == 1.49
async def test_supervisor_api_stats_failure(
api_client: TestClient, coresys: CoreSys, caplog: pytest.LogCaptureFixture
):
"""Test supervisor stats failure."""
coresys.docker.containers.get.side_effect = DockerException("fail")
resp = await api_client.get("/supervisor/stats")
assert resp.status == 500
body = await resp.json()
assert (
body["message"]
== "Can't get stats for Supervisor container. Check supervisor logs for details (check with 'ha supervisor logs')"
)
assert body["error_key"] == "supervisor_stats_error"
assert body["extra_fields"] == {"logs_command": "ha supervisor logs"}
assert "Could not inspect container 'hassio_supervisor': fail" in caplog.text

View File

@@ -675,3 +675,50 @@ async def test_install_progress_handles_layers_skipping_download(
assert job.done is True
assert job.progress == 100
capture_exception.assert_not_called()
async def test_install_progress_handles_containerd_snapshotter(
coresys: CoreSys,
test_docker_interface: DockerInterface,
capture_exception: Mock,
):
"""Test install handles containerd snapshotter format where extraction has no total bytes.
With containerd snapshotter, the extraction phase reports time elapsed in seconds
rather than bytes extracted. The progress_detail has format:
{"current": <seconds>, "units": "s"} with total=None
This test ensures we handle this gracefully by using the download size for
aggregate progress calculation.
"""
coresys.core.set_state(CoreState.RUNNING)
# Fixture emulates containerd snapshotter pull log format
coresys.docker.docker.api.pull.return_value = load_json_fixture(
"docker_pull_image_log_containerd.json"
)
with patch.object(
type(coresys.supervisor), "arch", PropertyMock(return_value="i386")
):
event = asyncio.Event()
job, install_task = coresys.jobs.schedule_job(
test_docker_interface.install,
JobSchedulerOptions(),
AwesomeVersion("1.2.3"),
"test",
)
async def listen_for_job_end(reference: SupervisorJob):
if reference.uuid != job.uuid:
return
event.set()
coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_END, listen_for_job_end)
await install_task
await event.wait()
# Job should complete successfully without exceptions
assert job.done is True
assert job.progress == 100
capture_exception.assert_not_called()

View File

@@ -0,0 +1,122 @@
[
{
"status": "Pulling from home-assistant/test-image",
"id": "2025.7.1"
},
{
"status": "Pulling fs layer",
"progressDetail": {},
"id": "layer1"
},
{
"status": "Pulling fs layer",
"progressDetail": {},
"id": "layer2"
},
{
"status": "Downloading",
"progressDetail": {
"current": 1048576,
"total": 5178461
},
"progress": "[===========> ] 1.049MB/5.178MB",
"id": "layer1"
},
{
"status": "Downloading",
"progressDetail": {
"current": 5178461,
"total": 5178461
},
"progress": "[==================================================>] 5.178MB/5.178MB",
"id": "layer1"
},
{
"status": "Download complete",
"progressDetail": {
"hidecounts": true
},
"id": "layer1"
},
{
"status": "Downloading",
"progressDetail": {
"current": 1048576,
"total": 10485760
},
"progress": "[=====> ] 1.049MB/10.49MB",
"id": "layer2"
},
{
"status": "Downloading",
"progressDetail": {
"current": 10485760,
"total": 10485760
},
"progress": "[==================================================>] 10.49MB/10.49MB",
"id": "layer2"
},
{
"status": "Download complete",
"progressDetail": {
"hidecounts": true
},
"id": "layer2"
},
{
"status": "Extracting",
"progressDetail": {
"current": 1,
"units": "s"
},
"progress": "1 s",
"id": "layer1"
},
{
"status": "Extracting",
"progressDetail": {
"current": 5,
"units": "s"
},
"progress": "5 s",
"id": "layer1"
},
{
"status": "Pull complete",
"progressDetail": {
"hidecounts": true
},
"id": "layer1"
},
{
"status": "Extracting",
"progressDetail": {
"current": 1,
"units": "s"
},
"progress": "1 s",
"id": "layer2"
},
{
"status": "Extracting",
"progressDetail": {
"current": 3,
"units": "s"
},
"progress": "3 s",
"id": "layer2"
},
{
"status": "Pull complete",
"progressDetail": {
"hidecounts": true
},
"id": "layer2"
},
{
"status": "Digest: sha256:abc123"
},
{
"status": "Status: Downloaded newer image for test/image:2025.7.1"
}
]