mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-11-19 07:50:17 +00:00
Compare commits
12 Commits
2025.11.2
...
remove-unk
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1609f8d2f5 | ||
|
|
15c1d77638 | ||
|
|
462a4daf60 | ||
|
|
95c106d502 | ||
|
|
74f9431519 | ||
|
|
0eef2169f7 | ||
|
|
2656b451cd | ||
|
|
af7a629dd4 | ||
|
|
30cc172199 | ||
|
|
69ae8db13c | ||
|
|
d85aedc42b | ||
|
|
d541fe5c3a |
8
.github/workflows/builder.yml
vendored
8
.github/workflows/builder.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
||||
requirements: ${{ steps.requirements.outputs.changed }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
arch: ${{ fromJson(needs.init.outputs.architectures) }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -178,7 +178,7 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
if: needs.init.outputs.publish == 'true'
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Initialize git
|
||||
if: needs.init.outputs.publish == 'true'
|
||||
@@ -203,7 +203,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
# home-assistant/builder doesn't support sha pinning
|
||||
- name: Build the Supervisor
|
||||
|
||||
20
.github/workflows/ci.yaml
vendored
20
.github/workflows/ci.yaml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
name: Prepare Python dependencies
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
- name: Set up Python
|
||||
id: python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
id: python
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
id: python
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
- name: Register hadolint problem matcher
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/hadolint.json"
|
||||
@@ -169,7 +169,7 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
id: python
|
||||
@@ -213,7 +213,7 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
id: python
|
||||
@@ -257,7 +257,7 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
id: python
|
||||
@@ -293,7 +293,7 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
id: python
|
||||
@@ -339,7 +339,7 @@ jobs:
|
||||
name: Run tests Python ${{ needs.prepare.outputs.python-version }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
id: python
|
||||
@@ -398,7 +398,7 @@ jobs:
|
||||
needs: ["pytest", "prepare"]
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
id: python
|
||||
|
||||
2
.github/workflows/release-drafter.yml
vendored
2
.github/workflows/release-drafter.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
name: Release Drafter
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
2
.github/workflows/sentry.yaml
vendored
2
.github/workflows/sentry.yaml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
- name: Sentry Release
|
||||
uses: getsentry/action-release@128c5058bbbe93c8e02147fe0a9c713f166259a6 # v3.4.0
|
||||
env:
|
||||
|
||||
4
.github/workflows/update_frontend.yml
vendored
4
.github/workflows/update_frontend.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
latest_version: ${{ steps.latest_frontend_version.outputs.latest_tag }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
- name: Get latest frontend release
|
||||
id: latest_frontend_version
|
||||
uses: abatilo/release-info-action@32cb932219f1cee3fc4f4a298fd65ead5d35b661 # v1.3.3
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
if: needs.check-version.outputs.skip != 'true'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
- name: Clear www folder
|
||||
run: |
|
||||
rm -rf supervisor/api/panel/*
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
aiodns==3.5.0
|
||||
aiodocker==0.24.0
|
||||
aiohttp==3.13.2
|
||||
atomicwrites-homeassistant==1.4.1
|
||||
attrs==25.4.0
|
||||
@@ -24,7 +25,7 @@ pyudev==0.24.4
|
||||
PyYAML==6.0.3
|
||||
requests==2.32.5
|
||||
securetar==2025.2.1
|
||||
sentry-sdk==2.43.0
|
||||
sentry-sdk==2.44.0
|
||||
setuptools==80.9.0
|
||||
voluptuous==0.15.2
|
||||
dbus-fast==2.45.1
|
||||
|
||||
@@ -2,13 +2,13 @@ astroid==4.0.2
|
||||
coverage==7.11.3
|
||||
mypy==1.18.2
|
||||
pre-commit==4.4.0
|
||||
pylint==4.0.2
|
||||
pylint==4.0.3
|
||||
pytest-aiohttp==1.1.0
|
||||
pytest-asyncio==1.2.0
|
||||
pytest-asyncio==1.3.0
|
||||
pytest-cov==7.0.0
|
||||
pytest-timeout==2.4.0
|
||||
pytest==8.4.2
|
||||
ruff==0.14.4
|
||||
pytest==9.0.1
|
||||
ruff==0.14.5
|
||||
time-machine==2.19.0
|
||||
types-docker==7.1.0.20251009
|
||||
types-pyyaml==6.0.12.20250915
|
||||
|
||||
@@ -66,13 +66,21 @@ from ..docker.const import ContainerState
|
||||
from ..docker.monitor import DockerContainerStateEvent
|
||||
from ..docker.stats import DockerStats
|
||||
from ..exceptions import (
|
||||
AddonConfigurationError,
|
||||
AddonBackupMetadataInvalidError,
|
||||
AddonConfigurationInvalidError,
|
||||
AddonNotRunningError,
|
||||
AddonNotSupportedError,
|
||||
AddonNotSupportedWriteStdinError,
|
||||
AddonPrePostBackupCommandReturnedError,
|
||||
AddonsError,
|
||||
AddonsJobError,
|
||||
AddonUnknownError,
|
||||
BackupRestoreUnknownError,
|
||||
ConfigurationFileError,
|
||||
DockerBuildError,
|
||||
DockerError,
|
||||
HostAppArmorError,
|
||||
StoreAddonNotFoundError,
|
||||
)
|
||||
from ..hardware.data import Device
|
||||
from ..homeassistant.const import WSEvent
|
||||
@@ -235,7 +243,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):
|
||||
with suppress(DockerError, AddonNotSupportedError):
|
||||
await self.instance.install(self.version, default_image, arch=self.arch)
|
||||
|
||||
self.persist[ATTR_IMAGE] = default_image
|
||||
@@ -718,18 +726,16 @@ 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:
|
||||
_LOGGER.error(
|
||||
"Add-on %s has invalid options: %s",
|
||||
self.slug,
|
||||
humanize_error(self.options, ex),
|
||||
)
|
||||
except ConfigurationFileError:
|
||||
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 can't write options", self.slug)
|
||||
else:
|
||||
_LOGGER.debug("Add-on %s write options: %s", self.slug, options)
|
||||
return
|
||||
raise AddonUnknownError(addon=self.slug) from err
|
||||
|
||||
raise AddonConfigurationError()
|
||||
_LOGGER.debug("Add-on %s write options: %s", self.slug, options)
|
||||
|
||||
@Job(
|
||||
name="addon_unload",
|
||||
@@ -772,7 +778,7 @@ class Addon(AddonModel):
|
||||
async def install(self) -> None:
|
||||
"""Install and setup this addon."""
|
||||
if not self.addon_store:
|
||||
raise AddonsError("Missing from store, cannot install!")
|
||||
raise StoreAddonNotFoundError(addon=self.slug)
|
||||
|
||||
await self.sys_addons.data.install(self.addon_store)
|
||||
|
||||
@@ -793,9 +799,17 @@ class Addon(AddonModel):
|
||||
await self.instance.install(
|
||||
self.latest_version, self.addon_store.image, arch=self.arch
|
||||
)
|
||||
except DockerError as err:
|
||||
except AddonsError:
|
||||
await self.sys_addons.data.uninstall(self)
|
||||
raise AddonsError() from err
|
||||
raise
|
||||
except DockerBuildError as err:
|
||||
_LOGGER.error("Could not build image for addon %s: %s", self.slug, err)
|
||||
await self.sys_addons.data.uninstall(self)
|
||||
raise AddonUnknownError(addon=self.slug) from err
|
||||
except DockerError as err:
|
||||
_LOGGER.error("Could not pull image to update addon %s: %s", self.slug, err)
|
||||
await self.sys_addons.data.uninstall(self)
|
||||
raise AddonUnknownError(addon=self.slug) from err
|
||||
|
||||
# Finish initialization and set up listeners
|
||||
await self.load()
|
||||
@@ -819,7 +833,8 @@ class Addon(AddonModel):
|
||||
try:
|
||||
await self.instance.remove(remove_image=remove_image)
|
||||
except DockerError as err:
|
||||
raise AddonsError() from err
|
||||
_LOGGER.error("Could not remove image for addon %s: %s", self.slug, err)
|
||||
raise AddonUnknownError(addon=self.slug) from err
|
||||
|
||||
self.state = AddonState.UNKNOWN
|
||||
|
||||
@@ -884,7 +899,7 @@ class Addon(AddonModel):
|
||||
if it was running. Else nothing is returned.
|
||||
"""
|
||||
if not self.addon_store:
|
||||
raise AddonsError("Missing from store, cannot update!")
|
||||
raise StoreAddonNotFoundError(addon=self.slug)
|
||||
|
||||
old_image = self.image
|
||||
# Cache data to prevent races with other updates to global
|
||||
@@ -892,8 +907,12 @@ class Addon(AddonModel):
|
||||
|
||||
try:
|
||||
await self.instance.update(store.version, store.image, arch=self.arch)
|
||||
except DockerBuildError as err:
|
||||
_LOGGER.error("Could not build image for addon %s: %s", self.slug, err)
|
||||
raise AddonUnknownError(addon=self.slug) from err
|
||||
except DockerError as err:
|
||||
raise AddonsError() from err
|
||||
_LOGGER.error("Could not pull image to update addon %s: %s", self.slug, err)
|
||||
raise AddonUnknownError(addon=self.slug) from err
|
||||
|
||||
# Stop the addon if running
|
||||
if (last_state := self.state) in {AddonState.STARTED, AddonState.STARTUP}:
|
||||
@@ -935,12 +954,23 @@ class Addon(AddonModel):
|
||||
"""
|
||||
last_state: AddonState = self.state
|
||||
try:
|
||||
# remove docker container but not addon config
|
||||
# remove docker container and image but not addon config
|
||||
try:
|
||||
await self.instance.remove()
|
||||
await self.instance.install(self.version)
|
||||
except DockerError as err:
|
||||
raise AddonsError() from err
|
||||
_LOGGER.error("Could not remove image for addon %s: %s", self.slug, err)
|
||||
raise AddonUnknownError(addon=self.slug) from err
|
||||
|
||||
try:
|
||||
await self.instance.install(self.version)
|
||||
except DockerBuildError as err:
|
||||
_LOGGER.error("Could not build image for addon %s: %s", self.slug, err)
|
||||
raise AddonUnknownError(addon=self.slug) from err
|
||||
except DockerError as err:
|
||||
_LOGGER.error(
|
||||
"Could not pull image to update addon %s: %s", self.slug, err
|
||||
)
|
||||
raise AddonUnknownError(addon=self.slug) from err
|
||||
|
||||
if self.addon_store:
|
||||
await self.sys_addons.data.update(self.addon_store)
|
||||
@@ -1111,8 +1141,9 @@ class Addon(AddonModel):
|
||||
try:
|
||||
await self.instance.run()
|
||||
except DockerError as err:
|
||||
_LOGGER.error("Could not start container for addon %s: %s", self.slug, err)
|
||||
self.state = AddonState.ERROR
|
||||
raise AddonsError() from err
|
||||
raise AddonUnknownError(addon=self.slug) from err
|
||||
|
||||
return self.sys_create_task(self._wait_for_startup())
|
||||
|
||||
@@ -1127,8 +1158,9 @@ class Addon(AddonModel):
|
||||
try:
|
||||
await self.instance.stop()
|
||||
except DockerError as err:
|
||||
_LOGGER.error("Could not stop container for addon %s: %s", self.slug, err)
|
||||
self.state = AddonState.ERROR
|
||||
raise AddonsError() from err
|
||||
raise AddonUnknownError(addon=self.slug) from err
|
||||
|
||||
@Job(
|
||||
name="addon_restart",
|
||||
@@ -1161,9 +1193,15 @@ 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 AddonsError() from err
|
||||
_LOGGER.error(
|
||||
"Could not get stats of container for addon %s: %s", self.slug, err
|
||||
)
|
||||
raise AddonUnknownError(addon=self.slug) from err
|
||||
|
||||
@Job(
|
||||
name="addon_write_stdin",
|
||||
@@ -1173,14 +1211,18 @@ class Addon(AddonModel):
|
||||
async def write_stdin(self, data) -> None:
|
||||
"""Write data to add-on stdin."""
|
||||
if not self.with_stdin:
|
||||
raise AddonNotSupportedError(
|
||||
f"Add-on {self.slug} does not support writing to stdin!", _LOGGER.error
|
||||
)
|
||||
raise AddonNotSupportedWriteStdinError(_LOGGER.error, addon=self.slug)
|
||||
|
||||
try:
|
||||
return await self.instance.write_stdin(data)
|
||||
if not await self.is_running():
|
||||
raise AddonNotRunningError(_LOGGER.warning, addon=self.slug)
|
||||
|
||||
await self.instance.write_stdin(data)
|
||||
except DockerError as err:
|
||||
raise AddonsError() from err
|
||||
_LOGGER.error(
|
||||
"Could not write stdin to container for addon %s: %s", self.slug, err
|
||||
)
|
||||
raise AddonUnknownError(addon=self.slug) from err
|
||||
|
||||
async def _backup_command(self, command: str) -> None:
|
||||
try:
|
||||
@@ -1189,15 +1231,14 @@ class Addon(AddonModel):
|
||||
_LOGGER.debug(
|
||||
"Pre-/Post backup command failed with: %s", command_return.output
|
||||
)
|
||||
raise AddonsError(
|
||||
f"Pre-/Post backup command returned error code: {command_return.exit_code}",
|
||||
_LOGGER.error,
|
||||
raise AddonPrePostBackupCommandReturnedError(
|
||||
_LOGGER.error, addon=self.slug, exit_code=command_return.exit_code
|
||||
)
|
||||
except DockerError as err:
|
||||
raise AddonsError(
|
||||
f"Failed running pre-/post backup command {command}: {str(err)}",
|
||||
_LOGGER.error,
|
||||
) from err
|
||||
_LOGGER.error(
|
||||
"Failed running pre-/post backup command %s: %s", command, err
|
||||
)
|
||||
raise AddonUnknownError(addon=self.slug) from err
|
||||
|
||||
@Job(
|
||||
name="addon_begin_backup",
|
||||
@@ -1286,15 +1327,14 @@ class Addon(AddonModel):
|
||||
try:
|
||||
self.instance.export_image(temp_path.joinpath("image.tar"))
|
||||
except DockerError as err:
|
||||
raise AddonsError() from err
|
||||
raise BackupRestoreUnknownError() from err
|
||||
|
||||
# Store local configs/state
|
||||
try:
|
||||
write_json_file(temp_path.joinpath("addon.json"), metadata)
|
||||
except ConfigurationFileError as err:
|
||||
raise AddonsError(
|
||||
f"Can't save meta for {self.slug}", _LOGGER.error
|
||||
) from err
|
||||
_LOGGER.error("Can't save meta for %s: %s", self.slug, err)
|
||||
raise BackupRestoreUnknownError() from err
|
||||
|
||||
# Store AppArmor Profile
|
||||
if apparmor_profile:
|
||||
@@ -1304,9 +1344,7 @@ class Addon(AddonModel):
|
||||
apparmor_profile, profile_backup_file
|
||||
)
|
||||
except HostAppArmorError as err:
|
||||
raise AddonsError(
|
||||
"Can't backup AppArmor profile", _LOGGER.error
|
||||
) from err
|
||||
raise BackupRestoreUnknownError() from err
|
||||
|
||||
# Write tarfile
|
||||
with tar_file as backup:
|
||||
@@ -1360,7 +1398,8 @@ class Addon(AddonModel):
|
||||
)
|
||||
_LOGGER.info("Finish backup for addon %s", self.slug)
|
||||
except (tarfile.TarError, OSError, AddFileError) as err:
|
||||
raise AddonsError(f"Can't write tarfile: {err}", _LOGGER.error) from err
|
||||
_LOGGER.error("Can't write backup tarfile for addon %s: %s", self.slug, err)
|
||||
raise BackupRestoreUnknownError() from err
|
||||
finally:
|
||||
if was_running:
|
||||
wait_for_start = await self.end_backup()
|
||||
@@ -1402,28 +1441,24 @@ class Addon(AddonModel):
|
||||
try:
|
||||
tmp, data = await self.sys_run_in_executor(_extract_tarfile)
|
||||
except tarfile.TarError as err:
|
||||
raise AddonsError(
|
||||
f"Can't read tarfile {tar_file}: {err}", _LOGGER.error
|
||||
) from err
|
||||
_LOGGER.error("Can't extract backup tarfile for %s: %s", self.slug, err)
|
||||
raise BackupRestoreUnknownError() from err
|
||||
except ConfigurationFileError as err:
|
||||
raise AddonsError() from err
|
||||
raise AddonUnknownError(addon=self.slug) from err
|
||||
|
||||
try:
|
||||
# Validate
|
||||
try:
|
||||
data = SCHEMA_ADDON_BACKUP(data)
|
||||
except vol.Invalid as err:
|
||||
raise AddonsError(
|
||||
f"Can't validate {self.slug}, backup data: {humanize_error(data, err)}",
|
||||
raise AddonBackupMetadataInvalidError(
|
||||
_LOGGER.error,
|
||||
addon=self.slug,
|
||||
validation_error=humanize_error(data, err),
|
||||
) from err
|
||||
|
||||
# If available
|
||||
if not self._available(data[ATTR_SYSTEM]):
|
||||
raise AddonNotSupportedError(
|
||||
f"Add-on {self.slug} is not available for this platform",
|
||||
_LOGGER.error,
|
||||
)
|
||||
# Validate availability. Raises if not
|
||||
self._validate_availability(data[ATTR_SYSTEM], logger=_LOGGER.error)
|
||||
|
||||
# Restore local add-on information
|
||||
_LOGGER.info("Restore config for addon %s", self.slug)
|
||||
@@ -1482,9 +1517,10 @@ class Addon(AddonModel):
|
||||
try:
|
||||
await self.sys_run_in_executor(_restore_data)
|
||||
except shutil.Error as err:
|
||||
raise AddonsError(
|
||||
f"Can't restore origin data: {err}", _LOGGER.error
|
||||
) from err
|
||||
_LOGGER.error(
|
||||
"Can't restore origin data for %s: %s", self.slug, err
|
||||
)
|
||||
raise BackupRestoreUnknownError() from err
|
||||
|
||||
# Restore AppArmor
|
||||
profile_file = Path(tmp.name, "apparmor.txt")
|
||||
@@ -1495,10 +1531,11 @@ class Addon(AddonModel):
|
||||
)
|
||||
except HostAppArmorError as err:
|
||||
_LOGGER.error(
|
||||
"Can't restore AppArmor profile for add-on %s",
|
||||
"Can't restore AppArmor profile for add-on %s: %s",
|
||||
self.slug,
|
||||
err,
|
||||
)
|
||||
raise AddonsError() from err
|
||||
raise BackupRestoreUnknownError() from err
|
||||
|
||||
finally:
|
||||
# Is add-on loaded
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import cached_property
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -19,13 +20,20 @@ from ..const import (
|
||||
)
|
||||
from ..coresys import CoreSys, CoreSysAttributes
|
||||
from ..docker.interface import MAP_ARCH
|
||||
from ..exceptions import ConfigurationFileError, HassioArchNotFound
|
||||
from ..exceptions import (
|
||||
AddonBuildArchitectureNotSupportedError,
|
||||
AddonBuildDockerfileMissingError,
|
||||
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."""
|
||||
@@ -106,7 +114,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) -> bool:
|
||||
async def is_valid(self) -> None:
|
||||
"""Return true if the build env is valid."""
|
||||
|
||||
def build_is_valid() -> bool:
|
||||
@@ -118,9 +126,17 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
|
||||
)
|
||||
|
||||
try:
|
||||
return await self.sys_run_in_executor(build_is_valid)
|
||||
if not await self.sys_run_in_executor(build_is_valid):
|
||||
raise AddonBuildDockerfileMissingError(
|
||||
_LOGGER.error, addon=self.addon.slug
|
||||
)
|
||||
except HassioArchNotFound:
|
||||
return False
|
||||
raise AddonBuildArchitectureNotSupportedError(
|
||||
_LOGGER.error,
|
||||
addon=self.addon.slug,
|
||||
addon_arch_list=self.addon.supported_arch,
|
||||
system_arch_list=self.sys_arch.supported,
|
||||
) from None
|
||||
|
||||
def get_docker_args(
|
||||
self, version: AwesomeVersion, image_tag: str
|
||||
|
||||
@@ -100,6 +100,8 @@ from ..const import (
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..docker.stats import DockerStats
|
||||
from ..exceptions import (
|
||||
AddonBootConfigCannotChangeError,
|
||||
AddonConfigurationInvalidError,
|
||||
APIAddonNotInstalled,
|
||||
APIError,
|
||||
APIForbidden,
|
||||
@@ -125,6 +127,7 @@ 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),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -300,19 +303,20 @@ 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(addon_schema, request)
|
||||
body = await api_validate(SCHEMA_OPTIONS, request)
|
||||
if ATTR_OPTIONS in body:
|
||||
addon.options = body[ATTR_OPTIONS]
|
||||
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
|
||||
if ATTR_BOOT in body:
|
||||
if addon.boot_config == AddonBootConfig.MANUAL_ONLY:
|
||||
raise APIError(
|
||||
f"Addon {addon.slug} boot option is set to {addon.boot_config} so it cannot be changed"
|
||||
raise AddonBootConfigCannotChangeError(
|
||||
addon=addon.slug, boot_config=addon.boot_config.value
|
||||
)
|
||||
addon.boot = body[ATTR_BOOT]
|
||||
if ATTR_AUTO_UPDATE in body:
|
||||
|
||||
@@ -53,7 +53,7 @@ from ..const import (
|
||||
REQUEST_FROM,
|
||||
)
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..exceptions import APIError, APIForbidden, APINotFound
|
||||
from ..exceptions import APIError, APIForbidden, APINotFound, StoreAddonNotFoundError
|
||||
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 APINotFound(f"Addon {addon_slug} does not exist")
|
||||
raise StoreAddonNotFoundError(addon=addon_slug)
|
||||
|
||||
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 APINotFound(f"Addon {addon_slug} does not exist in the store")
|
||||
raise StoreAddonNotFoundError(addon=addon_slug)
|
||||
return addon.addon_store
|
||||
|
||||
return addon
|
||||
|
||||
@@ -9,8 +9,10 @@ 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 (
|
||||
AuthError,
|
||||
AuthHomeAssistantAPIValidationError,
|
||||
AuthInvalidNoneValueError,
|
||||
AuthListUsersError,
|
||||
AuthListUsersNoneResponseError,
|
||||
AuthPasswordResetError,
|
||||
HomeAssistantAPIError,
|
||||
HomeAssistantWSError,
|
||||
@@ -83,10 +85,8 @@ class Auth(FileConfiguration, CoreSysAttributes):
|
||||
self, addon: Addon, username: str | None, password: str | None
|
||||
) -> bool:
|
||||
"""Check username login."""
|
||||
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)
|
||||
if username is None or password is None:
|
||||
raise AuthInvalidNoneValueError(_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 AuthError()
|
||||
raise AuthHomeAssistantAPIValidationError()
|
||||
|
||||
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()
|
||||
raise AuthPasswordResetError(user=username)
|
||||
|
||||
async def list_users(self) -> list[dict[str, Any]]:
|
||||
"""List users on the Home Assistant instance."""
|
||||
@@ -166,15 +166,12 @@ class Auth(FileConfiguration, CoreSysAttributes):
|
||||
{ATTR_TYPE: "config/auth/list"}
|
||||
)
|
||||
except HomeAssistantWSError as err:
|
||||
raise AuthListUsersError(
|
||||
f"Can't request listing users on Home Assistant: {err}", _LOGGER.error
|
||||
) from err
|
||||
_LOGGER.error("Can't request listing users on Home Assistant: %s", err)
|
||||
raise AuthListUsersError() from err
|
||||
|
||||
if users is not None:
|
||||
return users
|
||||
raise AuthListUsersError(
|
||||
"Can't request listing users on Home Assistant!", _LOGGER.error
|
||||
)
|
||||
raise AuthListUsersNoneResponseError(_LOGGER.error)
|
||||
|
||||
@staticmethod
|
||||
def _rehash(value: str, salt2: str = "") -> str:
|
||||
|
||||
@@ -628,9 +628,6 @@ class Backup(JobGroup):
|
||||
if start_task := await self._addon_save(addon):
|
||||
start_tasks.append(start_task)
|
||||
except BackupError as err:
|
||||
err = BackupError(
|
||||
f"Can't backup add-on {addon.slug}: {str(err)}", _LOGGER.error
|
||||
)
|
||||
self.sys_jobs.current.capture_error(err)
|
||||
|
||||
return start_tasks
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncio import Task
|
||||
from collections.abc import Callable, Coroutine
|
||||
import logging
|
||||
from typing import Any
|
||||
@@ -38,11 +39,13 @@ class Bus(CoreSysAttributes):
|
||||
self._listeners.setdefault(event, []).append(listener)
|
||||
return listener
|
||||
|
||||
def fire_event(self, event: BusEvent, reference: Any) -> None:
|
||||
def fire_event(self, event: BusEvent, reference: Any) -> list[Task]:
|
||||
"""Fire an event to the bus."""
|
||||
_LOGGER.debug("Fire event '%s' with '%s'", event, reference)
|
||||
tasks: list[Task] = []
|
||||
for listener in self._listeners.get(event, []):
|
||||
self.sys_create_task(listener.callback(reference))
|
||||
tasks.append(self.sys_create_task(listener.callback(reference)))
|
||||
return tasks
|
||||
|
||||
def remove_listener(self, listener: EventListener) -> None:
|
||||
"""Unregister an listener."""
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable
|
||||
from contextlib import suppress
|
||||
from ipaddress import IPv4Address
|
||||
import logging
|
||||
@@ -9,6 +10,7 @@ import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
import aiodocker
|
||||
from attr import evolve
|
||||
from awesomeversion import AwesomeVersion
|
||||
import docker
|
||||
@@ -32,6 +34,7 @@ from ..coresys import CoreSys
|
||||
from ..exceptions import (
|
||||
CoreDNSError,
|
||||
DBusError,
|
||||
DockerBuildError,
|
||||
DockerError,
|
||||
DockerJobError,
|
||||
DockerNotFound,
|
||||
@@ -679,9 +682,8 @@ 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()
|
||||
if not await build_env.is_valid():
|
||||
_LOGGER.error("Invalid build environment, can't build this add-on!")
|
||||
raise DockerError()
|
||||
# Check if the build environment is valid, raises if not
|
||||
await build_env.is_valid()
|
||||
|
||||
_LOGGER.info("Starting build for %s:%s", self.image, version)
|
||||
|
||||
@@ -717,21 +719,24 @@ class DockerAddon(DockerInterface):
|
||||
error_message = f"Docker build failed for {addon_image_tag} (exit code {result.exit_code}). Build output:\n{logs}"
|
||||
raise docker.errors.DockerException(error_message)
|
||||
|
||||
addon_image = self.sys_docker.images.get(addon_image_tag)
|
||||
|
||||
return addon_image, logs
|
||||
return addon_image_tag, logs
|
||||
|
||||
try:
|
||||
docker_image, log = await self.sys_run_in_executor(build_image)
|
||||
addon_image_tag, log = await self.sys_run_in_executor(build_image)
|
||||
|
||||
_LOGGER.debug("Build %s:%s done: %s", self.image, version, log)
|
||||
|
||||
# Update meta data
|
||||
self._meta = docker_image.attrs
|
||||
self._meta = await self.sys_docker.images.inspect(addon_image_tag)
|
||||
|
||||
except (docker.errors.DockerException, requests.RequestException) as err:
|
||||
_LOGGER.error("Can't build %s:%s: %s", self.image, version, err)
|
||||
raise DockerError() from err
|
||||
except (
|
||||
docker.errors.DockerException,
|
||||
requests.RequestException,
|
||||
aiodocker.DockerError,
|
||||
) as err:
|
||||
raise DockerBuildError(
|
||||
f"Can't build {self.image}:{version}: {err!s}", _LOGGER.error
|
||||
) from err
|
||||
|
||||
_LOGGER.info("Build %s:%s done", self.image, version)
|
||||
|
||||
@@ -751,11 +756,8 @@ class DockerAddon(DockerInterface):
|
||||
)
|
||||
async def import_image(self, tar_file: Path) -> None:
|
||||
"""Import a tar file as image."""
|
||||
docker_image = await self.sys_run_in_executor(
|
||||
self.sys_docker.import_image, tar_file
|
||||
)
|
||||
if docker_image:
|
||||
self._meta = docker_image.attrs
|
||||
if docker_image := await self.sys_docker.import_image(tar_file):
|
||||
self._meta = docker_image
|
||||
_LOGGER.info("Importing image %s and version %s", tar_file, self.version)
|
||||
|
||||
with suppress(DockerError):
|
||||
@@ -769,17 +771,21 @@ class DockerAddon(DockerInterface):
|
||||
version: AwesomeVersion | None = None,
|
||||
) -> None:
|
||||
"""Check if old version exists and cleanup other versions of image not in use."""
|
||||
await self.sys_run_in_executor(
|
||||
self.sys_docker.cleanup_old_images,
|
||||
(image := image or self.image),
|
||||
version or self.version,
|
||||
if not (use_image := image or self.image):
|
||||
raise DockerError("Cannot determine image from metadata!", _LOGGER.error)
|
||||
if not (use_version := version or self.version):
|
||||
raise DockerError("Cannot determine version from metadata!", _LOGGER.error)
|
||||
|
||||
await self.sys_docker.cleanup_old_images(
|
||||
use_image,
|
||||
use_version,
|
||||
{old_image} if old_image else None,
|
||||
keep_images={
|
||||
f"{addon.image}:{addon.version}"
|
||||
for addon in self.sys_addons.installed
|
||||
if addon.slug != self.addon.slug
|
||||
and addon.image
|
||||
and addon.image in {old_image, image}
|
||||
and addon.image in {old_image, use_image}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -788,12 +794,9 @@ class DockerAddon(DockerInterface):
|
||||
on_condition=DockerJobError,
|
||||
concurrency=JobConcurrency.GROUP_REJECT,
|
||||
)
|
||||
async def write_stdin(self, data: bytes) -> None:
|
||||
def write_stdin(self, data: bytes) -> Awaitable[None]:
|
||||
"""Write to add-on stdin."""
|
||||
if not await self.is_running():
|
||||
raise DockerError()
|
||||
|
||||
await self.sys_run_in_executor(self._write_stdin, data)
|
||||
return self.sys_run_in_executor(self._write_stdin, data)
|
||||
|
||||
def _write_stdin(self, data: bytes) -> None:
|
||||
"""Write to add-on stdin.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Init file for Supervisor Docker object."""
|
||||
|
||||
from collections.abc import Awaitable
|
||||
from ipaddress import IPv4Address
|
||||
import logging
|
||||
import re
|
||||
@@ -236,11 +235,10 @@ class DockerHomeAssistant(DockerInterface):
|
||||
environment={ENV_TIME: self.sys_timezone},
|
||||
)
|
||||
|
||||
def is_initialize(self) -> Awaitable[bool]:
|
||||
async def is_initialize(self) -> bool:
|
||||
"""Return True if Docker container exists."""
|
||||
return self.sys_run_in_executor(
|
||||
self.sys_docker.container_is_initialized,
|
||||
self.name,
|
||||
self.image,
|
||||
self.sys_homeassistant.version,
|
||||
if not self.sys_homeassistant.version:
|
||||
return False
|
||||
return await self.sys_docker.container_is_initialized(
|
||||
self.name, self.image, self.sys_homeassistant.version
|
||||
)
|
||||
|
||||
@@ -6,17 +6,18 @@ from abc import ABC, abstractmethod
|
||||
from collections import defaultdict
|
||||
from collections.abc import Awaitable
|
||||
from contextlib import suppress
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
import re
|
||||
from time import time
|
||||
from typing import Any, cast
|
||||
from uuid import uuid4
|
||||
|
||||
import aiodocker
|
||||
from awesomeversion import AwesomeVersion
|
||||
from awesomeversion.strategy import AwesomeVersionStrategy
|
||||
import docker
|
||||
from docker.models.containers import Container
|
||||
from docker.models.images import Image
|
||||
import requests
|
||||
|
||||
from ..bus import EventListener
|
||||
@@ -33,6 +34,7 @@ from ..coresys import CoreSys
|
||||
from ..exceptions import (
|
||||
DockerAPIError,
|
||||
DockerError,
|
||||
DockerHubRateLimitExceeded,
|
||||
DockerJobError,
|
||||
DockerLogOutOfOrder,
|
||||
DockerNotFound,
|
||||
@@ -215,7 +217,7 @@ class DockerInterface(JobGroup, ABC):
|
||||
if not credentials:
|
||||
return
|
||||
|
||||
await self.sys_run_in_executor(self.sys_docker.docker.login, **credentials)
|
||||
await self.sys_run_in_executor(self.sys_docker.dockerpy.login, **credentials)
|
||||
|
||||
def _process_pull_image_log( # noqa: C901
|
||||
self, install_job_id: str, reference: PullLogEntry
|
||||
@@ -418,8 +420,7 @@ class DockerInterface(JobGroup, ABC):
|
||||
)
|
||||
|
||||
# Pull new image
|
||||
docker_image = await self.sys_run_in_executor(
|
||||
self.sys_docker.pull_image,
|
||||
docker_image = await self.sys_docker.pull_image(
|
||||
self.sys_jobs.current.uuid,
|
||||
image,
|
||||
str(version),
|
||||
@@ -431,22 +432,37 @@ class DockerInterface(JobGroup, ABC):
|
||||
_LOGGER.info(
|
||||
"Tagging image %s with version %s as latest", image, version
|
||||
)
|
||||
await self.sys_run_in_executor(docker_image.tag, image, tag="latest")
|
||||
await self.sys_docker.images.tag(
|
||||
docker_image["Id"], image, tag="latest"
|
||||
)
|
||||
except docker.errors.APIError as err:
|
||||
if err.status_code == 429:
|
||||
if err.status_code == HTTPStatus.TOO_MANY_REQUESTS:
|
||||
self.sys_resolution.create_issue(
|
||||
IssueType.DOCKER_RATELIMIT,
|
||||
ContextType.SYSTEM,
|
||||
suggestions=[SuggestionType.REGISTRY_LOGIN],
|
||||
)
|
||||
_LOGGER.info(
|
||||
"Your IP address has made too many requests to Docker Hub which activated a rate limit. "
|
||||
"For more details see https://www.home-assistant.io/more-info/dockerhub-rate-limit"
|
||||
)
|
||||
raise DockerHubRateLimitExceeded(_LOGGER.error) from err
|
||||
await async_capture_exception(err)
|
||||
raise DockerError(
|
||||
f"Can't install {image}:{version!s}: {err}", _LOGGER.error
|
||||
) from err
|
||||
except (docker.errors.DockerException, requests.RequestException) as err:
|
||||
except aiodocker.DockerError as err:
|
||||
if err.status == HTTPStatus.TOO_MANY_REQUESTS:
|
||||
self.sys_resolution.create_issue(
|
||||
IssueType.DOCKER_RATELIMIT,
|
||||
ContextType.SYSTEM,
|
||||
suggestions=[SuggestionType.REGISTRY_LOGIN],
|
||||
)
|
||||
raise DockerHubRateLimitExceeded(_LOGGER.error) from err
|
||||
await async_capture_exception(err)
|
||||
raise DockerError(
|
||||
f"Can't install {image}:{version!s}: {err}", _LOGGER.error
|
||||
) from err
|
||||
except (
|
||||
docker.errors.DockerException,
|
||||
requests.RequestException,
|
||||
) as err:
|
||||
await async_capture_exception(err)
|
||||
raise DockerError(
|
||||
f"Unknown error with {image}:{version!s} -> {err!s}", _LOGGER.error
|
||||
@@ -455,46 +471,43 @@ class DockerInterface(JobGroup, ABC):
|
||||
if listener:
|
||||
self.sys_bus.remove_listener(listener)
|
||||
|
||||
self._meta = docker_image.attrs
|
||||
self._meta = docker_image
|
||||
|
||||
async def exists(self) -> bool:
|
||||
"""Return True if Docker image exists in local repository."""
|
||||
with suppress(docker.errors.DockerException, requests.RequestException):
|
||||
await self.sys_run_in_executor(
|
||||
self.sys_docker.images.get, f"{self.image}:{self.version!s}"
|
||||
)
|
||||
with suppress(aiodocker.DockerError, requests.RequestException):
|
||||
await self.sys_docker.images.inspect(f"{self.image}:{self.version!s}")
|
||||
return True
|
||||
return False
|
||||
|
||||
async def is_running(self) -> bool:
|
||||
"""Return True if Docker is running."""
|
||||
async def _get_container(self) -> Container | None:
|
||||
"""Get docker container, returns None if not found."""
|
||||
try:
|
||||
docker_container = await self.sys_run_in_executor(
|
||||
return await self.sys_run_in_executor(
|
||||
self.sys_docker.containers.get, self.name
|
||||
)
|
||||
except docker.errors.NotFound:
|
||||
return False
|
||||
return None
|
||||
except docker.errors.DockerException as err:
|
||||
raise DockerAPIError() from err
|
||||
raise DockerAPIError(
|
||||
f"Docker API error occurred while getting container information: {err!s}"
|
||||
) from err
|
||||
except requests.RequestException as err:
|
||||
raise DockerRequestError() from err
|
||||
raise DockerRequestError(
|
||||
f"Error communicating with Docker to get container information: {err!s}"
|
||||
) from err
|
||||
|
||||
return docker_container.status == "running"
|
||||
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
|
||||
|
||||
async def current_state(self) -> ContainerState:
|
||||
"""Return current state of container."""
|
||||
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)
|
||||
if docker_container := await self._get_container():
|
||||
return _container_state_from_model(docker_container)
|
||||
return ContainerState.UNKNOWN
|
||||
|
||||
@Job(name="docker_interface_attach", concurrency=JobConcurrency.GROUP_QUEUE)
|
||||
async def attach(
|
||||
@@ -521,15 +534,17 @@ class DockerInterface(JobGroup, ABC):
|
||||
),
|
||||
)
|
||||
|
||||
with suppress(docker.errors.DockerException, requests.RequestException):
|
||||
with suppress(aiodocker.DockerError, requests.RequestException):
|
||||
if not self._meta and self.image:
|
||||
self._meta = self.sys_docker.images.get(
|
||||
self._meta = await self.sys_docker.images.inspect(
|
||||
f"{self.image}:{version!s}"
|
||||
).attrs
|
||||
)
|
||||
|
||||
# Successful?
|
||||
if not self._meta:
|
||||
raise DockerError()
|
||||
raise DockerError(
|
||||
f"Could not get metadata on container or image for {self.name}"
|
||||
)
|
||||
_LOGGER.info("Attaching to %s with version %s", self.image, self.version)
|
||||
|
||||
@Job(
|
||||
@@ -593,14 +608,17 @@ class DockerInterface(JobGroup, ABC):
|
||||
)
|
||||
async def remove(self, *, remove_image: bool = True) -> None:
|
||||
"""Remove Docker images."""
|
||||
if not self.image or not self.version:
|
||||
raise DockerError(
|
||||
"Cannot determine image and/or version from metadata!", _LOGGER.error
|
||||
)
|
||||
|
||||
# Cleanup container
|
||||
with suppress(DockerError):
|
||||
await self.stop()
|
||||
|
||||
if remove_image:
|
||||
await self.sys_run_in_executor(
|
||||
self.sys_docker.remove_image, self.image, self.version
|
||||
)
|
||||
await self.sys_docker.remove_image(self.image, self.version)
|
||||
|
||||
self._meta = None
|
||||
|
||||
@@ -622,18 +640,16 @@ class DockerInterface(JobGroup, ABC):
|
||||
image_name = f"{expected_image}:{version!s}"
|
||||
if self.image == expected_image:
|
||||
try:
|
||||
image: Image = await self.sys_run_in_executor(
|
||||
self.sys_docker.images.get, image_name
|
||||
)
|
||||
except (docker.errors.DockerException, requests.RequestException) as err:
|
||||
image = await self.sys_docker.images.inspect(image_name)
|
||||
except (aiodocker.DockerError, requests.RequestException) as err:
|
||||
raise DockerError(
|
||||
f"Could not get {image_name} for check due to: {err!s}",
|
||||
_LOGGER.error,
|
||||
) from err
|
||||
|
||||
image_arch = f"{image.attrs['Os']}/{image.attrs['Architecture']}"
|
||||
if "Variant" in image.attrs:
|
||||
image_arch = f"{image_arch}/{image.attrs['Variant']}"
|
||||
image_arch = f"{image['Os']}/{image['Architecture']}"
|
||||
if "Variant" in image:
|
||||
image_arch = f"{image_arch}/{image['Variant']}"
|
||||
|
||||
# If we have an image and its the right arch, all set
|
||||
# It seems that newer Docker version return a variant for arm64 images.
|
||||
@@ -695,11 +711,13 @@ class DockerInterface(JobGroup, ABC):
|
||||
version: AwesomeVersion | None = None,
|
||||
) -> None:
|
||||
"""Check if old version exists and cleanup."""
|
||||
await self.sys_run_in_executor(
|
||||
self.sys_docker.cleanup_old_images,
|
||||
image or self.image,
|
||||
version or self.version,
|
||||
{old_image} if old_image else None,
|
||||
if not (use_image := image or self.image):
|
||||
raise DockerError("Cannot determine image from metadata!", _LOGGER.error)
|
||||
if not (use_version := version or self.version):
|
||||
raise DockerError("Cannot determine version from metadata!", _LOGGER.error)
|
||||
|
||||
await self.sys_docker.cleanup_old_images(
|
||||
use_image, use_version, {old_image} if old_image else None
|
||||
)
|
||||
|
||||
@Job(
|
||||
@@ -731,14 +749,8 @@ class DockerInterface(JobGroup, ABC):
|
||||
|
||||
async def is_failed(self) -> bool:
|
||||
"""Return True if Docker is failing state."""
|
||||
try:
|
||||
docker_container = await self.sys_run_in_executor(
|
||||
self.sys_docker.containers.get, self.name
|
||||
)
|
||||
except docker.errors.NotFound:
|
||||
if not (docker_container := await self._get_container()):
|
||||
return False
|
||||
except (docker.errors.DockerException, requests.RequestException) as err:
|
||||
raise DockerError() from err
|
||||
|
||||
# container is not running
|
||||
if docker_container.status != "exited":
|
||||
@@ -751,10 +763,10 @@ class DockerInterface(JobGroup, ABC):
|
||||
"""Return latest version of local image."""
|
||||
available_version: list[AwesomeVersion] = []
|
||||
try:
|
||||
for image in await self.sys_run_in_executor(
|
||||
self.sys_docker.images.list, self.image
|
||||
for image in await self.sys_docker.images.list(
|
||||
filters=f'{{"reference": ["{self.image}"]}}'
|
||||
):
|
||||
for tag in image.tags:
|
||||
for tag in image["RepoTags"]:
|
||||
version = AwesomeVersion(tag.partition(":")[2])
|
||||
if version.strategy == AwesomeVersionStrategy.UNKNOWN:
|
||||
continue
|
||||
@@ -763,7 +775,7 @@ class DockerInterface(JobGroup, ABC):
|
||||
if not available_version:
|
||||
raise ValueError()
|
||||
|
||||
except (docker.errors.DockerException, ValueError) as err:
|
||||
except (aiodocker.DockerError, ValueError) as err:
|
||||
raise DockerNotFound(
|
||||
f"No version found for {self.image}", _LOGGER.info
|
||||
) from err
|
||||
|
||||
@@ -6,20 +6,24 @@ import asyncio
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
from http import HTTPStatus
|
||||
from ipaddress import IPv4Address
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any, Final, Self, cast
|
||||
|
||||
import aiodocker
|
||||
from aiodocker.images import DockerImages
|
||||
from aiohttp import ClientSession, ClientTimeout, UnixConnector
|
||||
import attr
|
||||
from awesomeversion import AwesomeVersion, AwesomeVersionCompareException
|
||||
from docker import errors as docker_errors
|
||||
from docker.api.client import APIClient
|
||||
from docker.client import DockerClient
|
||||
from docker.errors import DockerException, ImageNotFound, NotFound
|
||||
from docker.models.containers import Container, ContainerCollection
|
||||
from docker.models.images import Image, ImageCollection
|
||||
from docker.models.networks import Network
|
||||
from docker.types.daemon import CancellableStream
|
||||
import requests
|
||||
@@ -53,6 +57,7 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
MIN_SUPPORTED_DOCKER: Final = AwesomeVersion("24.0.0")
|
||||
DOCKER_NETWORK_HOST: Final = "host"
|
||||
RE_IMPORT_IMAGE_STREAM = re.compile(r"(^Loaded image ID: |^Loaded image: )(.+)$")
|
||||
|
||||
|
||||
@attr.s(frozen=True)
|
||||
@@ -204,7 +209,15 @@ class DockerAPI(CoreSysAttributes):
|
||||
def __init__(self, coresys: CoreSys):
|
||||
"""Initialize Docker base wrapper."""
|
||||
self.coresys = coresys
|
||||
self._docker: DockerClient | None = None
|
||||
# We keep both until we can fully refactor to aiodocker
|
||||
self._dockerpy: DockerClient | None = None
|
||||
self.docker: aiodocker.Docker = aiodocker.Docker(
|
||||
url="unix://localhost", # dummy hostname for URL composition
|
||||
connector=(connector := UnixConnector(SOCKET_DOCKER.as_posix())),
|
||||
session=ClientSession(connector=connector, timeout=ClientTimeout(900)),
|
||||
api_version="auto",
|
||||
)
|
||||
|
||||
self._network: DockerNetwork | None = None
|
||||
self._info: DockerInfo | None = None
|
||||
self.config: DockerConfig = DockerConfig()
|
||||
@@ -212,28 +225,28 @@ class DockerAPI(CoreSysAttributes):
|
||||
|
||||
async def post_init(self) -> Self:
|
||||
"""Post init actions that must be done in event loop."""
|
||||
self._docker = await asyncio.get_running_loop().run_in_executor(
|
||||
self._dockerpy = await asyncio.get_running_loop().run_in_executor(
|
||||
None,
|
||||
partial(
|
||||
DockerClient,
|
||||
base_url=f"unix:/{str(SOCKET_DOCKER)}",
|
||||
base_url=f"unix:/{SOCKET_DOCKER.as_posix()}",
|
||||
version="auto",
|
||||
timeout=900,
|
||||
),
|
||||
)
|
||||
self._info = DockerInfo.new(self.docker.info())
|
||||
self._info = DockerInfo.new(self.dockerpy.info())
|
||||
await self.config.read_data()
|
||||
self._network = await DockerNetwork(self.docker).post_init(
|
||||
self._network = await DockerNetwork(self.dockerpy).post_init(
|
||||
self.config.enable_ipv6, self.config.mtu
|
||||
)
|
||||
return self
|
||||
|
||||
@property
|
||||
def docker(self) -> DockerClient:
|
||||
def dockerpy(self) -> DockerClient:
|
||||
"""Get docker API client."""
|
||||
if not self._docker:
|
||||
if not self._dockerpy:
|
||||
raise RuntimeError("Docker API Client not initialized!")
|
||||
return self._docker
|
||||
return self._dockerpy
|
||||
|
||||
@property
|
||||
def network(self) -> DockerNetwork:
|
||||
@@ -243,19 +256,19 @@ class DockerAPI(CoreSysAttributes):
|
||||
return self._network
|
||||
|
||||
@property
|
||||
def images(self) -> ImageCollection:
|
||||
def images(self) -> DockerImages:
|
||||
"""Return API images."""
|
||||
return self.docker.images
|
||||
|
||||
@property
|
||||
def containers(self) -> ContainerCollection:
|
||||
"""Return API containers."""
|
||||
return self.docker.containers
|
||||
return self.dockerpy.containers
|
||||
|
||||
@property
|
||||
def api(self) -> APIClient:
|
||||
"""Return API containers."""
|
||||
return self.docker.api
|
||||
return self.dockerpy.api
|
||||
|
||||
@property
|
||||
def info(self) -> DockerInfo:
|
||||
@@ -267,7 +280,7 @@ class DockerAPI(CoreSysAttributes):
|
||||
@property
|
||||
def events(self) -> CancellableStream:
|
||||
"""Return docker event stream."""
|
||||
return self.docker.events(decode=True)
|
||||
return self.dockerpy.events(decode=True)
|
||||
|
||||
@property
|
||||
def monitor(self) -> DockerMonitor:
|
||||
@@ -383,7 +396,7 @@ class DockerAPI(CoreSysAttributes):
|
||||
with suppress(DockerError):
|
||||
self.network.detach_default_bridge(container)
|
||||
else:
|
||||
host_network: Network = self.docker.networks.get(DOCKER_NETWORK_HOST)
|
||||
host_network: Network = self.dockerpy.networks.get(DOCKER_NETWORK_HOST)
|
||||
|
||||
# Check if container is register on host
|
||||
# https://github.com/moby/moby/issues/23302
|
||||
@@ -410,35 +423,32 @@ class DockerAPI(CoreSysAttributes):
|
||||
|
||||
return container
|
||||
|
||||
def pull_image(
|
||||
async def pull_image(
|
||||
self,
|
||||
job_id: str,
|
||||
repository: str,
|
||||
tag: str = "latest",
|
||||
platform: str | None = None,
|
||||
) -> Image:
|
||||
) -> dict[str, Any]:
|
||||
"""Pull the specified image and return it.
|
||||
|
||||
This mimics the high level API of images.pull but provides better error handling by raising
|
||||
based on a docker error on pull. Whereas the high level API ignores all errors on pull and
|
||||
raises only if the get fails afterwards. Additionally it fires progress reports for the pull
|
||||
on the bus so listeners can use that to update status for users.
|
||||
|
||||
Must be run in executor.
|
||||
"""
|
||||
pull_log = self.docker.api.pull(
|
||||
repository, tag=tag, platform=platform, stream=True, decode=True
|
||||
)
|
||||
for e in pull_log:
|
||||
async for e in self.images.pull(
|
||||
repository, tag=tag, platform=platform, stream=True
|
||||
):
|
||||
entry = PullLogEntry.from_pull_log_dict(job_id, e)
|
||||
if entry.error:
|
||||
raise entry.exception
|
||||
self.sys_loop.call_soon_threadsafe(
|
||||
self.sys_bus.fire_event, BusEvent.DOCKER_IMAGE_PULL_UPDATE, entry
|
||||
await asyncio.gather(
|
||||
*self.sys_bus.fire_event(BusEvent.DOCKER_IMAGE_PULL_UPDATE, entry)
|
||||
)
|
||||
|
||||
sep = "@" if tag.startswith("sha256:") else ":"
|
||||
return self.images.get(f"{repository}{sep}{tag}")
|
||||
return await self.images.inspect(f"{repository}{sep}{tag}")
|
||||
|
||||
def run_command(
|
||||
self,
|
||||
@@ -459,7 +469,7 @@ class DockerAPI(CoreSysAttributes):
|
||||
_LOGGER.info("Runing command '%s' on %s", command, image_with_tag)
|
||||
container = None
|
||||
try:
|
||||
container = self.docker.containers.run(
|
||||
container = self.dockerpy.containers.run(
|
||||
image_with_tag,
|
||||
command=command,
|
||||
detach=True,
|
||||
@@ -487,35 +497,35 @@ class DockerAPI(CoreSysAttributes):
|
||||
"""Repair local docker overlayfs2 issues."""
|
||||
_LOGGER.info("Prune stale containers")
|
||||
try:
|
||||
output = self.docker.api.prune_containers()
|
||||
output = self.dockerpy.api.prune_containers()
|
||||
_LOGGER.debug("Containers prune: %s", output)
|
||||
except docker_errors.APIError as err:
|
||||
_LOGGER.warning("Error for containers prune: %s", err)
|
||||
|
||||
_LOGGER.info("Prune stale images")
|
||||
try:
|
||||
output = self.docker.api.prune_images(filters={"dangling": False})
|
||||
output = self.dockerpy.api.prune_images(filters={"dangling": False})
|
||||
_LOGGER.debug("Images prune: %s", output)
|
||||
except docker_errors.APIError as err:
|
||||
_LOGGER.warning("Error for images prune: %s", err)
|
||||
|
||||
_LOGGER.info("Prune stale builds")
|
||||
try:
|
||||
output = self.docker.api.prune_builds()
|
||||
output = self.dockerpy.api.prune_builds()
|
||||
_LOGGER.debug("Builds prune: %s", output)
|
||||
except docker_errors.APIError as err:
|
||||
_LOGGER.warning("Error for builds prune: %s", err)
|
||||
|
||||
_LOGGER.info("Prune stale volumes")
|
||||
try:
|
||||
output = self.docker.api.prune_builds()
|
||||
output = self.dockerpy.api.prune_volumes()
|
||||
_LOGGER.debug("Volumes prune: %s", output)
|
||||
except docker_errors.APIError as err:
|
||||
_LOGGER.warning("Error for volumes prune: %s", err)
|
||||
|
||||
_LOGGER.info("Prune stale networks")
|
||||
try:
|
||||
output = self.docker.api.prune_networks()
|
||||
output = self.dockerpy.api.prune_networks()
|
||||
_LOGGER.debug("Networks prune: %s", output)
|
||||
except docker_errors.APIError as err:
|
||||
_LOGGER.warning("Error for networks prune: %s", err)
|
||||
@@ -537,11 +547,11 @@ class DockerAPI(CoreSysAttributes):
|
||||
|
||||
Fix: https://github.com/moby/moby/issues/23302
|
||||
"""
|
||||
network: Network = self.docker.networks.get(network_name)
|
||||
network: Network = self.dockerpy.networks.get(network_name)
|
||||
|
||||
for cid, data in network.attrs.get("Containers", {}).items():
|
||||
try:
|
||||
self.docker.containers.get(cid)
|
||||
self.dockerpy.containers.get(cid)
|
||||
continue
|
||||
except docker_errors.NotFound:
|
||||
_LOGGER.debug(
|
||||
@@ -556,22 +566,32 @@ class DockerAPI(CoreSysAttributes):
|
||||
with suppress(docker_errors.DockerException, requests.RequestException):
|
||||
network.disconnect(data.get("Name", cid), force=True)
|
||||
|
||||
def container_is_initialized(
|
||||
async def container_is_initialized(
|
||||
self, name: str, image: str, version: AwesomeVersion
|
||||
) -> bool:
|
||||
"""Return True if docker container exists in good state and is built from expected image."""
|
||||
try:
|
||||
docker_container = self.containers.get(name)
|
||||
docker_image = self.images.get(f"{image}:{version}")
|
||||
except NotFound:
|
||||
docker_container = await self.sys_run_in_executor(self.containers.get, name)
|
||||
docker_image = await self.images.inspect(f"{image}:{version}")
|
||||
except docker_errors.NotFound:
|
||||
return False
|
||||
except (DockerException, requests.RequestException) as err:
|
||||
raise DockerError() from err
|
||||
except aiodocker.DockerError as err:
|
||||
if err.status == HTTPStatus.NOT_FOUND:
|
||||
return False
|
||||
raise DockerError(
|
||||
f"Could not get container {name} or image {image}:{version} to check state: {err!s}",
|
||||
_LOGGER.error,
|
||||
) from err
|
||||
except (docker_errors.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
|
||||
|
||||
# Check the image is correct and state is good
|
||||
return (
|
||||
docker_container.image is not None
|
||||
and docker_container.image.id == docker_image.id
|
||||
and docker_container.image.id == docker_image["Id"]
|
||||
and docker_container.status in ("exited", "running", "created")
|
||||
)
|
||||
|
||||
@@ -581,18 +601,22 @@ class DockerAPI(CoreSysAttributes):
|
||||
"""Stop/remove Docker container."""
|
||||
try:
|
||||
docker_container: Container = self.containers.get(name)
|
||||
except NotFound:
|
||||
except docker_errors.NotFound:
|
||||
# Generally suppressed so we don't log this
|
||||
raise DockerNotFound() from None
|
||||
except (DockerException, requests.RequestException) as err:
|
||||
raise DockerError() from err
|
||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
||||
raise DockerError(
|
||||
f"Could not get container {name} for stopping: {err!s}",
|
||||
_LOGGER.error,
|
||||
) from err
|
||||
|
||||
if docker_container.status == "running":
|
||||
_LOGGER.info("Stopping %s application", name)
|
||||
with suppress(DockerException, requests.RequestException):
|
||||
with suppress(docker_errors.DockerException, requests.RequestException):
|
||||
docker_container.stop(timeout=timeout)
|
||||
|
||||
if remove_container:
|
||||
with suppress(DockerException, requests.RequestException):
|
||||
with suppress(docker_errors.DockerException, requests.RequestException):
|
||||
_LOGGER.info("Cleaning %s application", name)
|
||||
docker_container.remove(force=True, v=True)
|
||||
|
||||
@@ -604,11 +628,11 @@ class DockerAPI(CoreSysAttributes):
|
||||
"""Start Docker container."""
|
||||
try:
|
||||
docker_container: Container = self.containers.get(name)
|
||||
except NotFound:
|
||||
except docker_errors.NotFound:
|
||||
raise DockerNotFound(
|
||||
f"{name} not found for starting up", _LOGGER.error
|
||||
) from None
|
||||
except (DockerException, requests.RequestException) as err:
|
||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
||||
raise DockerError(
|
||||
f"Could not get {name} for starting up", _LOGGER.error
|
||||
) from err
|
||||
@@ -616,36 +640,44 @@ class DockerAPI(CoreSysAttributes):
|
||||
_LOGGER.info("Starting %s", name)
|
||||
try:
|
||||
docker_container.start()
|
||||
except (DockerException, requests.RequestException) as err:
|
||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
||||
raise DockerError(f"Can't start {name}: {err}", _LOGGER.error) from err
|
||||
|
||||
def restart_container(self, name: str, timeout: int) -> None:
|
||||
"""Restart docker container."""
|
||||
try:
|
||||
container: Container = self.containers.get(name)
|
||||
except NotFound:
|
||||
raise DockerNotFound() from None
|
||||
except (DockerException, requests.RequestException) as err:
|
||||
raise DockerError() from err
|
||||
except docker_errors.NotFound:
|
||||
raise DockerNotFound(
|
||||
f"Container {name} not found for restarting", _LOGGER.warning
|
||||
) from None
|
||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
||||
raise DockerError(
|
||||
f"Could not get container {name} for restarting: {err!s}", _LOGGER.error
|
||||
) from err
|
||||
|
||||
_LOGGER.info("Restarting %s", name)
|
||||
try:
|
||||
container.restart(timeout=timeout)
|
||||
except (DockerException, requests.RequestException) as err:
|
||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
||||
raise DockerError(f"Can't restart {name}: {err}", _LOGGER.warning) from err
|
||||
|
||||
def container_logs(self, name: str, tail: int = 100) -> bytes:
|
||||
"""Return Docker logs of container."""
|
||||
try:
|
||||
docker_container: Container = self.containers.get(name)
|
||||
except NotFound:
|
||||
raise DockerNotFound() from None
|
||||
except (DockerException, requests.RequestException) as err:
|
||||
raise DockerError() from err
|
||||
except docker_errors.NotFound:
|
||||
raise DockerNotFound(
|
||||
f"Container {name} not found for logs", _LOGGER.warning
|
||||
) from None
|
||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
||||
raise DockerError(
|
||||
f"Could not get container {name} for logs: {err!s}", _LOGGER.error
|
||||
) from err
|
||||
|
||||
try:
|
||||
return docker_container.logs(tail=tail, stdout=True, stderr=True)
|
||||
except (DockerException, requests.RequestException) as err:
|
||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
||||
raise DockerError(
|
||||
f"Can't grep logs from {name}: {err}", _LOGGER.warning
|
||||
) from err
|
||||
@@ -654,10 +686,14 @@ class DockerAPI(CoreSysAttributes):
|
||||
"""Read and return stats from container."""
|
||||
try:
|
||||
docker_container: Container = self.containers.get(name)
|
||||
except NotFound:
|
||||
raise DockerNotFound() from None
|
||||
except (DockerException, requests.RequestException) as err:
|
||||
raise DockerError() from err
|
||||
except docker_errors.NotFound:
|
||||
raise DockerNotFound(
|
||||
f"Container {name} not found for stats", _LOGGER.warning
|
||||
) from None
|
||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
||||
raise DockerError(
|
||||
f"Could not inspect container '{name}': {err!s}", _LOGGER.error
|
||||
) from err
|
||||
|
||||
# container is not running
|
||||
if docker_container.status != "running":
|
||||
@@ -665,7 +701,7 @@ class DockerAPI(CoreSysAttributes):
|
||||
|
||||
try:
|
||||
return docker_container.stats(stream=False)
|
||||
except (DockerException, requests.RequestException) as err:
|
||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
||||
raise DockerError(
|
||||
f"Can't read stats from {name}: {err}", _LOGGER.error
|
||||
) from err
|
||||
@@ -674,61 +710,90 @@ class DockerAPI(CoreSysAttributes):
|
||||
"""Execute a command inside Docker container."""
|
||||
try:
|
||||
docker_container: Container = self.containers.get(name)
|
||||
except NotFound:
|
||||
raise DockerNotFound() from None
|
||||
except (DockerException, requests.RequestException) as err:
|
||||
raise DockerError() from err
|
||||
except docker_errors.NotFound:
|
||||
raise DockerNotFound(
|
||||
f"Container {name} not found for running command", _LOGGER.warning
|
||||
) from None
|
||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
||||
raise DockerError(
|
||||
f"Can't get container {name} to run command: {err!s}"
|
||||
) from err
|
||||
|
||||
# Execute
|
||||
try:
|
||||
code, output = docker_container.exec_run(command)
|
||||
except (DockerException, requests.RequestException) as err:
|
||||
raise DockerError() from err
|
||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
||||
raise DockerError(
|
||||
f"Can't run command in container {name}: {err!s}"
|
||||
) from err
|
||||
|
||||
return CommandReturn(code, output)
|
||||
|
||||
def remove_image(
|
||||
async def remove_image(
|
||||
self, image: str, version: AwesomeVersion, latest: bool = True
|
||||
) -> None:
|
||||
"""Remove a Docker image by version and latest."""
|
||||
try:
|
||||
if latest:
|
||||
_LOGGER.info("Removing image %s with latest", image)
|
||||
with suppress(ImageNotFound):
|
||||
self.images.remove(image=f"{image}:latest", force=True)
|
||||
try:
|
||||
await self.images.delete(f"{image}:latest", force=True)
|
||||
except aiodocker.DockerError as err:
|
||||
if err.status != HTTPStatus.NOT_FOUND:
|
||||
raise
|
||||
|
||||
_LOGGER.info("Removing image %s with %s", image, version)
|
||||
with suppress(ImageNotFound):
|
||||
self.images.remove(image=f"{image}:{version!s}", force=True)
|
||||
try:
|
||||
await self.images.delete(f"{image}:{version!s}", force=True)
|
||||
except aiodocker.DockerError as err:
|
||||
if err.status != HTTPStatus.NOT_FOUND:
|
||||
raise
|
||||
|
||||
except (DockerException, requests.RequestException) as err:
|
||||
except (aiodocker.DockerError, requests.RequestException) as err:
|
||||
raise DockerError(
|
||||
f"Can't remove image {image}: {err}", _LOGGER.warning
|
||||
) from err
|
||||
|
||||
def import_image(self, tar_file: Path) -> Image | None:
|
||||
async def import_image(self, tar_file: Path) -> dict[str, Any] | None:
|
||||
"""Import a tar file as image."""
|
||||
try:
|
||||
with tar_file.open("rb") as read_tar:
|
||||
docker_image_list: list[Image] = self.images.load(read_tar) # type: ignore
|
||||
|
||||
if len(docker_image_list) != 1:
|
||||
_LOGGER.warning(
|
||||
"Unexpected image count %d while importing image from tar",
|
||||
len(docker_image_list),
|
||||
)
|
||||
return None
|
||||
return docker_image_list[0]
|
||||
except (DockerException, OSError) as err:
|
||||
resp: list[dict[str, Any]] = self.images.import_image(read_tar)
|
||||
except (aiodocker.DockerError, OSError) as err:
|
||||
raise DockerError(
|
||||
f"Can't import image from tar: {err}", _LOGGER.error
|
||||
) from err
|
||||
|
||||
docker_image_list: list[str] = []
|
||||
for chunk in resp:
|
||||
if "errorDetail" in chunk:
|
||||
raise DockerError(
|
||||
f"Can't import image from tar: {chunk['errorDetail']['message']}",
|
||||
_LOGGER.error,
|
||||
)
|
||||
if "stream" in chunk:
|
||||
if match := RE_IMPORT_IMAGE_STREAM.search(chunk["stream"]):
|
||||
docker_image_list.append(match.group(2))
|
||||
|
||||
if len(docker_image_list) != 1:
|
||||
_LOGGER.warning(
|
||||
"Unexpected image count %d while importing image from tar",
|
||||
len(docker_image_list),
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
return await self.images.inspect(docker_image_list[0])
|
||||
except (aiodocker.DockerError, requests.RequestException) as err:
|
||||
raise DockerError(
|
||||
f"Could not inspect imported image due to: {err!s}", _LOGGER.error
|
||||
) from err
|
||||
|
||||
def export_image(self, image: str, version: AwesomeVersion, tar_file: Path) -> None:
|
||||
"""Export current images into a tar file."""
|
||||
try:
|
||||
docker_image = self.api.get_image(f"{image}:{version}")
|
||||
except (DockerException, requests.RequestException) as err:
|
||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
||||
raise DockerError(
|
||||
f"Can't fetch image {image}: {err}", _LOGGER.error
|
||||
) from err
|
||||
@@ -745,7 +810,7 @@ class DockerAPI(CoreSysAttributes):
|
||||
|
||||
_LOGGER.info("Export image %s done", image)
|
||||
|
||||
def cleanup_old_images(
|
||||
async def cleanup_old_images(
|
||||
self,
|
||||
current_image: str,
|
||||
current_version: AwesomeVersion,
|
||||
@@ -756,46 +821,57 @@ class DockerAPI(CoreSysAttributes):
|
||||
"""Clean up old versions of an image."""
|
||||
image = f"{current_image}:{current_version!s}"
|
||||
try:
|
||||
keep = {cast(str, self.images.get(image).id)}
|
||||
except ImageNotFound:
|
||||
raise DockerNotFound(
|
||||
f"{current_image} not found for cleanup", _LOGGER.warning
|
||||
) from None
|
||||
except (DockerException, requests.RequestException) as err:
|
||||
try:
|
||||
image_attr = await self.images.inspect(image)
|
||||
except aiodocker.DockerError as err:
|
||||
if err.status == HTTPStatus.NOT_FOUND:
|
||||
raise DockerNotFound(
|
||||
f"{current_image} not found for cleanup", _LOGGER.warning
|
||||
) from None
|
||||
raise
|
||||
except (aiodocker.DockerError, requests.RequestException) as err:
|
||||
raise DockerError(
|
||||
f"Can't get {current_image} for cleanup", _LOGGER.warning
|
||||
) from err
|
||||
keep = {cast(str, image_attr["Id"])}
|
||||
|
||||
if keep_images:
|
||||
keep_images -= {image}
|
||||
try:
|
||||
for image in keep_images:
|
||||
# If its not found, no need to preserve it from getting removed
|
||||
with suppress(ImageNotFound):
|
||||
keep.add(cast(str, self.images.get(image).id))
|
||||
except (DockerException, requests.RequestException) as err:
|
||||
raise DockerError(
|
||||
f"Failed to get one or more images from {keep} during cleanup",
|
||||
_LOGGER.warning,
|
||||
) from err
|
||||
results = await asyncio.gather(
|
||||
*[self.images.inspect(image) for image in keep_images],
|
||||
return_exceptions=True,
|
||||
)
|
||||
for result in results:
|
||||
# If its not found, no need to preserve it from getting removed
|
||||
if (
|
||||
isinstance(result, aiodocker.DockerError)
|
||||
and result.status == HTTPStatus.NOT_FOUND
|
||||
):
|
||||
continue
|
||||
if isinstance(result, BaseException):
|
||||
raise DockerError(
|
||||
f"Failed to get one or more images from {keep} during cleanup",
|
||||
_LOGGER.warning,
|
||||
) from result
|
||||
keep.add(cast(str, result["Id"]))
|
||||
|
||||
# Cleanup old and current
|
||||
image_names = list(
|
||||
old_images | {current_image} if old_images else {current_image}
|
||||
)
|
||||
try:
|
||||
# This API accepts a list of image names. Tested and confirmed working on docker==7.1.0
|
||||
# Its typing does say only `str` though. Bit concerning, could an update break this?
|
||||
images_list = self.images.list(name=image_names) # type: ignore
|
||||
except (DockerException, requests.RequestException) as err:
|
||||
images_list = await self.images.list(
|
||||
filters=json.dumps({"reference": image_names})
|
||||
)
|
||||
except (aiodocker.DockerError, requests.RequestException) as err:
|
||||
raise DockerError(
|
||||
f"Corrupt docker overlayfs found: {err}", _LOGGER.warning
|
||||
) from err
|
||||
|
||||
for docker_image in images_list:
|
||||
if docker_image.id in keep:
|
||||
if docker_image["Id"] in keep:
|
||||
continue
|
||||
|
||||
with suppress(DockerException, requests.RequestException):
|
||||
_LOGGER.info("Cleanup images: %s", docker_image.tags)
|
||||
self.images.remove(docker_image.id, force=True)
|
||||
with suppress(aiodocker.DockerError, requests.RequestException):
|
||||
_LOGGER.info("Cleanup images: %s", docker_image["RepoTags"])
|
||||
await self.images.delete(docker_image["Id"], force=True)
|
||||
|
||||
@@ -89,7 +89,7 @@ class DockerMonitor(CoreSysAttributes, Thread):
|
||||
DockerContainerStateEvent(
|
||||
name=attributes["name"],
|
||||
state=container_state,
|
||||
id=event["id"],
|
||||
id=event["Actor"]["ID"],
|
||||
time=event["time"],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Init file for Supervisor Docker object."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable
|
||||
from ipaddress import IPv4Address
|
||||
import logging
|
||||
import os
|
||||
|
||||
import aiodocker
|
||||
from awesomeversion.awesomeversion import AwesomeVersion
|
||||
import docker
|
||||
import requests
|
||||
@@ -112,19 +114,18 @@ class DockerSupervisor(DockerInterface):
|
||||
name="docker_supervisor_update_start_tag",
|
||||
concurrency=JobConcurrency.GROUP_QUEUE,
|
||||
)
|
||||
def update_start_tag(self, image: str, version: AwesomeVersion) -> Awaitable[None]:
|
||||
async def update_start_tag(self, image: str, version: AwesomeVersion) -> None:
|
||||
"""Update start tag to new version."""
|
||||
return self.sys_run_in_executor(self._update_start_tag, image, version)
|
||||
|
||||
def _update_start_tag(self, image: str, version: AwesomeVersion) -> None:
|
||||
"""Update start tag to new version.
|
||||
|
||||
Need run inside executor.
|
||||
"""
|
||||
try:
|
||||
docker_container = self.sys_docker.containers.get(self.name)
|
||||
docker_image = self.sys_docker.images.get(f"{image}:{version!s}")
|
||||
except (docker.errors.DockerException, requests.RequestException) as err:
|
||||
docker_container = await self.sys_run_in_executor(
|
||||
self.sys_docker.containers.get, self.name
|
||||
)
|
||||
docker_image = await self.sys_docker.images.inspect(f"{image}:{version!s}")
|
||||
except (
|
||||
aiodocker.DockerError,
|
||||
docker.errors.DockerException,
|
||||
requests.RequestException,
|
||||
) as err:
|
||||
raise DockerError(
|
||||
f"Can't get image or container to fix start tag: {err}", _LOGGER.error
|
||||
) from err
|
||||
@@ -144,8 +145,14 @@ class DockerSupervisor(DockerInterface):
|
||||
# If version tag
|
||||
if start_tag != "latest":
|
||||
continue
|
||||
docker_image.tag(start_image, start_tag)
|
||||
docker_image.tag(start_image, version.string)
|
||||
await asyncio.gather(
|
||||
self.sys_docker.images.tag(
|
||||
docker_image["Id"], start_image, tag=start_tag
|
||||
),
|
||||
self.sys_docker.images.tag(
|
||||
docker_image["Id"], start_image, tag=version.string
|
||||
),
|
||||
)
|
||||
|
||||
except (docker.errors.DockerException, requests.RequestException) as err:
|
||||
except (aiodocker.DockerError, requests.RequestException) as err:
|
||||
raise DockerError(f"Can't fix start tag: {err}", _LOGGER.error) from err
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
extra_fields: dict[str, Any] | None = None,
|
||||
self, message: str | None = None, logger: Callable[..., None] | 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,6 +41,88 @@ 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 APITooManyRequests(APIError):
|
||||
"""API too many requests error."""
|
||||
|
||||
status = 429
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -122,6 +204,13 @@ class SupervisorAppArmorError(SupervisorError):
|
||||
"""Supervisor AppArmor error."""
|
||||
|
||||
|
||||
class SupervisorUnknownError(SupervisorError, APIUnknownSupervisorError):
|
||||
"""Raise when an unknown error occurs interacting with Supervisor or its container."""
|
||||
|
||||
error_key = "supervisor_unknown_error"
|
||||
message_template = "An unknown error occurred with Supervisor"
|
||||
|
||||
|
||||
class SupervisorJobError(SupervisorError, JobException):
|
||||
"""Raise on job errors."""
|
||||
|
||||
@@ -250,6 +339,54 @@ 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 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 AddonNotSupportedError(HassioNotSupportedError):
|
||||
"""Addon doesn't support a function."""
|
||||
|
||||
@@ -268,11 +405,8 @@ class AddonNotSupportedArchitectureError(AddonNotSupportedError):
|
||||
architectures: list[str],
|
||||
) -> None:
|
||||
"""Initialize exception."""
|
||||
super().__init__(
|
||||
None,
|
||||
logger,
|
||||
extra_fields={"slug": slug, "architectures": ", ".join(architectures)},
|
||||
)
|
||||
self.extra_fields = {"slug": slug, "architectures": ", ".join(architectures)}
|
||||
super().__init__(None, logger)
|
||||
|
||||
|
||||
class AddonNotSupportedMachineTypeError(AddonNotSupportedError):
|
||||
@@ -289,11 +423,8 @@ class AddonNotSupportedMachineTypeError(AddonNotSupportedError):
|
||||
machine_types: list[str],
|
||||
) -> None:
|
||||
"""Initialize exception."""
|
||||
super().__init__(
|
||||
None,
|
||||
logger,
|
||||
extra_fields={"slug": slug, "machine_types": ", ".join(machine_types)},
|
||||
)
|
||||
self.extra_fields = {"slug": slug, "machine_types": ", ".join(machine_types)}
|
||||
super().__init__(None, logger)
|
||||
|
||||
|
||||
class AddonNotSupportedHomeAssistantVersionError(AddonNotSupportedError):
|
||||
@@ -310,11 +441,80 @@ class AddonNotSupportedHomeAssistantVersionError(AddonNotSupportedError):
|
||||
version: str,
|
||||
) -> None:
|
||||
"""Initialize exception."""
|
||||
super().__init__(
|
||||
None,
|
||||
logger,
|
||||
extra_fields={"slug": slug, "version": version},
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
class AddonUnknownError(AddonsError, APIUnknownSupervisorError):
|
||||
"""Raise when unknown error occurs taking an action for an addon."""
|
||||
|
||||
error_key = "addon_unknown_error"
|
||||
message_template = "An unknown error occurred with addon {addon}"
|
||||
|
||||
def __init__(
|
||||
self, logger: Callable[..., None] | None = None, *, addon: str
|
||||
) -> None:
|
||||
"""Initialize exception."""
|
||||
self.extra_fields = {"addon": addon}
|
||||
super().__init__(logger)
|
||||
|
||||
|
||||
class AddonsJobError(AddonsError, JobException):
|
||||
@@ -346,13 +546,59 @@ class AuthError(HassioError):
|
||||
"""Auth errors."""
|
||||
|
||||
|
||||
class AuthPasswordResetError(HassioError):
|
||||
# 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):
|
||||
"""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}"
|
||||
)
|
||||
|
||||
class AuthListUsersError(HassioError):
|
||||
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):
|
||||
"""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
|
||||
|
||||
@@ -385,60 +631,6 @@ 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
|
||||
|
||||
|
||||
@@ -616,6 +808,10 @@ class DockerError(HassioError):
|
||||
"""Docker API/Transport errors."""
|
||||
|
||||
|
||||
class DockerBuildError(DockerError):
|
||||
"""Docker error during build."""
|
||||
|
||||
|
||||
class DockerAPIError(DockerError):
|
||||
"""Docker API error."""
|
||||
|
||||
@@ -639,9 +835,29 @@ class DockerLogOutOfOrder(DockerError):
|
||||
class DockerNoSpaceOnDevice(DockerError):
|
||||
"""Raise if a docker pull fails due to available space."""
|
||||
|
||||
error_key = "docker_no_space_on_device"
|
||||
message_template = "No space left on disk"
|
||||
|
||||
def __init__(self, logger: Callable[..., None] | None = None) -> None:
|
||||
"""Raise & log."""
|
||||
super().__init__("No space left on disk", logger=logger)
|
||||
super().__init__(None, logger=logger)
|
||||
|
||||
|
||||
class DockerHubRateLimitExceeded(DockerError, APITooManyRequests):
|
||||
"""Raise for docker hub rate limit exceeded error."""
|
||||
|
||||
error_key = "dockerhub_rate_limit_exceeded"
|
||||
message_template = (
|
||||
"Your IP address has made too many requests to Docker Hub which activated a rate limit. "
|
||||
"For more details see {dockerhub_rate_limit_url}"
|
||||
)
|
||||
extra_fields = {
|
||||
"dockerhub_rate_limit_url": "https://www.home-assistant.io/more-info/dockerhub-rate-limit"
|
||||
}
|
||||
|
||||
def __init__(self, logger: Callable[..., None] | None = None) -> None:
|
||||
"""Raise & log."""
|
||||
super().__init__(None, logger=logger)
|
||||
|
||||
|
||||
class DockerJobError(DockerError, JobException):
|
||||
@@ -712,6 +928,20 @@ 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."""
|
||||
|
||||
@@ -759,6 +989,55 @@ class BackupFileExistError(BackupError):
|
||||
"""Raise if the backup file already exists."""
|
||||
|
||||
|
||||
class AddonBackupMetadataInvalidError(BackupError, 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 AddonPrePostBackupCommandReturnedError(BackupError, 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 BackupRestoreUnknownError(BackupError, APIUnknownSupervisorError):
|
||||
"""Raise when an unknown error occurs during backup or restore."""
|
||||
|
||||
error_key = "backup_restore_unknown_error"
|
||||
message_template = "An unknown error occurred during backup/restore"
|
||||
|
||||
|
||||
# Security
|
||||
|
||||
|
||||
|
||||
@@ -102,13 +102,17 @@ class SupervisorJobError:
|
||||
"Unknown error, see Supervisor logs (check with 'ha supervisor logs')"
|
||||
)
|
||||
stage: str | None = None
|
||||
error_key: str | None = None
|
||||
extra_fields: dict[str, Any] | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, str | None]:
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return dictionary representation."""
|
||||
return {
|
||||
"type": self.type_.__name__,
|
||||
"message": self.message,
|
||||
"stage": self.stage,
|
||||
"error_key": self.error_key,
|
||||
"extra_fields": self.extra_fields,
|
||||
}
|
||||
|
||||
|
||||
@@ -158,7 +162,9 @@ class SupervisorJob:
|
||||
def capture_error(self, err: HassioError | None = None) -> None:
|
||||
"""Capture an error or record that an unknown error has occurred."""
|
||||
if err:
|
||||
new_error = SupervisorJobError(type(err), str(err), self.stage)
|
||||
new_error = SupervisorJobError(
|
||||
type(err), str(err), self.stage, err.error_key, err.extra_fields
|
||||
)
|
||||
else:
|
||||
new_error = SupervisorJobError(stage=self.stage)
|
||||
self.errors += [new_error]
|
||||
|
||||
@@ -64,6 +64,19 @@ def filter_data(coresys: CoreSys, event: Event, hint: Hint) -> Event | None:
|
||||
|
||||
# Not full startup - missing information
|
||||
if coresys.core.state in (CoreState.INITIALIZE, CoreState.SETUP):
|
||||
# During SETUP, we have basic system info available for better debugging
|
||||
if coresys.core.state == CoreState.SETUP:
|
||||
event.setdefault("contexts", {}).update(
|
||||
{
|
||||
"versions": {
|
||||
"docker": coresys.docker.info.version,
|
||||
"supervisor": coresys.supervisor.version,
|
||||
},
|
||||
"host": {
|
||||
"machine": coresys.machine,
|
||||
},
|
||||
}
|
||||
)
|
||||
return event
|
||||
|
||||
# List installed addons
|
||||
|
||||
@@ -28,8 +28,8 @@ from .exceptions import (
|
||||
DockerError,
|
||||
HostAppArmorError,
|
||||
SupervisorAppArmorError,
|
||||
SupervisorError,
|
||||
SupervisorJobError,
|
||||
SupervisorUnknownError,
|
||||
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 SupervisorError() from err
|
||||
raise SupervisorUnknownError() from err
|
||||
|
||||
async def repair(self):
|
||||
"""Repair local Supervisor data."""
|
||||
|
||||
@@ -3,24 +3,34 @@
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import errno
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, PropertyMock, call, patch
|
||||
|
||||
import aiodocker
|
||||
from awesomeversion import AwesomeVersion
|
||||
from docker.errors import DockerException, ImageNotFound, NotFound
|
||||
from docker.errors import APIError, DockerException, NotFound
|
||||
import pytest
|
||||
from securetar import SecureTarFile
|
||||
|
||||
from supervisor.addons.addon import Addon
|
||||
from supervisor.addons.const import AddonBackupMode
|
||||
from supervisor.addons.model import AddonModel
|
||||
from supervisor.config import CoreConfig
|
||||
from supervisor.const import AddonBoot, AddonState, BusEvent
|
||||
from supervisor.coresys import CoreSys
|
||||
from supervisor.docker.addon import DockerAddon
|
||||
from supervisor.docker.const import ContainerState
|
||||
from supervisor.docker.manager import CommandReturn
|
||||
from supervisor.docker.manager import CommandReturn, DockerAPI
|
||||
from supervisor.docker.monitor import DockerContainerStateEvent
|
||||
from supervisor.exceptions import AddonsError, AddonsJobError, AudioUpdateError
|
||||
from supervisor.exceptions import (
|
||||
AddonPrePostBackupCommandReturnedError,
|
||||
AddonsJobError,
|
||||
AddonUnknownError,
|
||||
AudioUpdateError,
|
||||
HassioError,
|
||||
)
|
||||
from supervisor.hardware.helper import HwHelper
|
||||
from supervisor.ingress import Ingress
|
||||
from supervisor.store.repository import Repository
|
||||
@@ -499,31 +509,26 @@ async def test_backup_with_pre_post_command(
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"get_error,exception_on_exec",
|
||||
("container_get_side_effect", "exec_run_side_effect", "exc_type_raised"),
|
||||
[
|
||||
(NotFound("missing"), False),
|
||||
(DockerException(), False),
|
||||
(None, True),
|
||||
(None, False),
|
||||
(NotFound("missing"), [(1, None)], AddonUnknownError),
|
||||
(DockerException(), [(1, None)], AddonUnknownError),
|
||||
(None, DockerException(), AddonUnknownError),
|
||||
(None, [(1, None)], AddonPrePostBackupCommandReturnedError),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("tmp_supervisor_data", "path_extern")
|
||||
async def test_backup_with_pre_command_error(
|
||||
coresys: CoreSys,
|
||||
install_addon_ssh: Addon,
|
||||
container: MagicMock,
|
||||
get_error: DockerException | None,
|
||||
exception_on_exec: bool,
|
||||
tmp_supervisor_data,
|
||||
path_extern,
|
||||
container_get_side_effect: DockerException | None,
|
||||
exec_run_side_effect: DockerException | list[tuple[int, Any]],
|
||||
exc_type_raised: type[HassioError],
|
||||
) -> None:
|
||||
"""Test backing up an addon with error running pre command."""
|
||||
if get_error:
|
||||
coresys.docker.containers.get.side_effect = get_error
|
||||
|
||||
if exception_on_exec:
|
||||
container.exec_run.side_effect = DockerException()
|
||||
else:
|
||||
container.exec_run.return_value = (1, None)
|
||||
coresys.docker.containers.get.side_effect = container_get_side_effect
|
||||
container.exec_run.side_effect = exec_run_side_effect
|
||||
|
||||
install_addon_ssh.path_data.mkdir()
|
||||
await install_addon_ssh.load()
|
||||
@@ -532,7 +537,7 @@ async def test_backup_with_pre_command_error(
|
||||
with (
|
||||
patch.object(DockerAddon, "is_running", return_value=True),
|
||||
patch.object(Addon, "backup_pre", new=PropertyMock(return_value="backup_pre")),
|
||||
pytest.raises(AddonsError),
|
||||
pytest.raises(exc_type_raised),
|
||||
):
|
||||
assert await install_addon_ssh.backup(tarfile) is None
|
||||
|
||||
@@ -861,16 +866,14 @@ async def test_addon_loads_wrong_image(
|
||||
|
||||
container.remove.assert_called_with(force=True, v=True)
|
||||
# one for removing the addon, one for removing the addon builder
|
||||
assert coresys.docker.images.remove.call_count == 2
|
||||
assert coresys.docker.images.delete.call_count == 2
|
||||
|
||||
assert coresys.docker.images.remove.call_args_list[0].kwargs == {
|
||||
"image": "local/aarch64-addon-ssh:latest",
|
||||
"force": True,
|
||||
}
|
||||
assert coresys.docker.images.remove.call_args_list[1].kwargs == {
|
||||
"image": "local/aarch64-addon-ssh:9.2.1",
|
||||
"force": True,
|
||||
}
|
||||
assert coresys.docker.images.delete.call_args_list[0] == call(
|
||||
"local/aarch64-addon-ssh:latest", force=True
|
||||
)
|
||||
assert coresys.docker.images.delete.call_args_list[1] == call(
|
||||
"local/aarch64-addon-ssh:9.2.1", force=True
|
||||
)
|
||||
mock_run_command.assert_called_once()
|
||||
assert mock_run_command.call_args.args[0] == "docker.io/library/docker"
|
||||
assert mock_run_command.call_args.kwargs["version"] == "1.0.0-cli"
|
||||
@@ -894,7 +897,9 @@ async def test_addon_loads_missing_image(
|
||||
mock_amd64_arch_supported,
|
||||
):
|
||||
"""Test addon corrects a missing image on load."""
|
||||
coresys.docker.images.get.side_effect = ImageNotFound("missing")
|
||||
coresys.docker.images.inspect.side_effect = aiodocker.DockerError(
|
||||
HTTPStatus.NOT_FOUND, {"message": "missing"}
|
||||
)
|
||||
|
||||
with (
|
||||
patch("pathlib.Path.is_file", return_value=True),
|
||||
@@ -926,41 +931,51 @@ async def test_addon_loads_missing_image(
|
||||
assert install_addon_ssh.image == "local/amd64-addon-ssh"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"pull_image_exc",
|
||||
[APIError("error"), aiodocker.DockerError(400, {"message": "error"})],
|
||||
)
|
||||
@pytest.mark.usefixtures("container", "mock_amd64_arch_supported")
|
||||
async def test_addon_load_succeeds_with_docker_errors(
|
||||
coresys: CoreSys,
|
||||
install_addon_ssh: Addon,
|
||||
container: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
mock_amd64_arch_supported,
|
||||
pull_image_exc: Exception,
|
||||
):
|
||||
"""Docker errors while building/pulling an image during load should not raise and fail setup."""
|
||||
# Build env invalid failure
|
||||
coresys.docker.images.get.side_effect = ImageNotFound("missing")
|
||||
coresys.docker.images.inspect.side_effect = aiodocker.DockerError(
|
||||
HTTPStatus.NOT_FOUND, {"message": "missing"}
|
||||
)
|
||||
caplog.clear()
|
||||
await install_addon_ssh.load()
|
||||
assert "Invalid build environment" in caplog.text
|
||||
assert "Cannot build addon 'local_ssh' because dockerfile is missing" in caplog.text
|
||||
|
||||
# Image build failure
|
||||
coresys.docker.images.build.side_effect = DockerException()
|
||||
caplog.clear()
|
||||
with (
|
||||
patch("pathlib.Path.is_file", return_value=True),
|
||||
patch.object(
|
||||
type(coresys.config),
|
||||
"local_to_extern_path",
|
||||
return_value="/addon/path/on/host",
|
||||
CoreConfig, "local_to_extern_path", return_value="/addon/path/on/host"
|
||||
),
|
||||
patch.object(
|
||||
DockerAPI,
|
||||
"run_command",
|
||||
return_value=MagicMock(exit_code=1, output=b"error"),
|
||||
),
|
||||
):
|
||||
await install_addon_ssh.load()
|
||||
assert "Can't build local/amd64-addon-ssh:9.2.1" in caplog.text
|
||||
assert (
|
||||
"Can't build local/amd64-addon-ssh:9.2.1: Docker build failed for local/amd64-addon-ssh:9.2.1 (exit code 1). Build output:\nerror"
|
||||
in caplog.text
|
||||
)
|
||||
|
||||
# Image pull failure
|
||||
install_addon_ssh.data["image"] = "test/amd64-addon-ssh"
|
||||
coresys.docker.images.build.reset_mock(side_effect=True)
|
||||
coresys.docker.pull_image.side_effect = DockerException()
|
||||
caplog.clear()
|
||||
await install_addon_ssh.load()
|
||||
assert "Unknown error with test/amd64-addon-ssh:9.2.1" in caplog.text
|
||||
with patch.object(DockerAPI, "pull_image", side_effect=pull_image_exc):
|
||||
await install_addon_ssh.load()
|
||||
assert "Can't install test/amd64-addon-ssh:9.2.1:" in caplog.text
|
||||
|
||||
|
||||
async def test_addon_manual_only_boot(coresys: CoreSys, install_addon_example: Addon):
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
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
|
||||
|
||||
@@ -102,11 +104,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()
|
||||
assert (await build.is_valid()) is None
|
||||
|
||||
|
||||
async def test_build_invalid(coresys: CoreSys, install_addon_ssh: Addon):
|
||||
"""Test platform set in docker args."""
|
||||
"""Test build not supported because Dockerfile missing for specified architecture."""
|
||||
build = await AddonBuild(coresys, install_addon_ssh).load_config()
|
||||
with (
|
||||
patch.object(
|
||||
@@ -115,5 +117,6 @@ 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),
|
||||
):
|
||||
assert not await build.is_valid()
|
||||
await build.is_valid()
|
||||
|
||||
@@ -4,7 +4,7 @@ import asyncio
|
||||
from collections.abc import AsyncGenerator, Generator
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, call, patch
|
||||
|
||||
from awesomeversion import AwesomeVersion
|
||||
import pytest
|
||||
@@ -514,19 +514,13 @@ async def test_shared_image_kept_on_uninstall(
|
||||
latest = f"{install_addon_example.image}:latest"
|
||||
|
||||
await coresys.addons.uninstall("local_example2")
|
||||
coresys.docker.images.remove.assert_not_called()
|
||||
coresys.docker.images.delete.assert_not_called()
|
||||
assert not coresys.addons.get("local_example2", local_only=True)
|
||||
|
||||
await coresys.addons.uninstall("local_example")
|
||||
assert coresys.docker.images.remove.call_count == 2
|
||||
assert coresys.docker.images.remove.call_args_list[0].kwargs == {
|
||||
"image": latest,
|
||||
"force": True,
|
||||
}
|
||||
assert coresys.docker.images.remove.call_args_list[1].kwargs == {
|
||||
"image": image,
|
||||
"force": True,
|
||||
}
|
||||
assert coresys.docker.images.delete.call_count == 2
|
||||
assert coresys.docker.images.delete.call_args_list[0] == call(latest, force=True)
|
||||
assert coresys.docker.images.delete.call_args_list[1] == call(image, force=True)
|
||||
assert not coresys.addons.get("local_example", local_only=True)
|
||||
|
||||
|
||||
@@ -554,19 +548,17 @@ async def test_shared_image_kept_on_update(
|
||||
assert example_2.version == "1.2.0"
|
||||
assert install_addon_example_image.version == "1.2.0"
|
||||
|
||||
image_new = MagicMock()
|
||||
image_new.id = "image_new"
|
||||
image_old = MagicMock()
|
||||
image_old.id = "image_old"
|
||||
docker.images.get.side_effect = [image_new, image_old]
|
||||
image_new = {"Id": "image_new", "RepoTags": ["image_new:latest"]}
|
||||
image_old = {"Id": "image_old", "RepoTags": ["image_old:latest"]}
|
||||
docker.images.inspect.side_effect = [image_new, image_old]
|
||||
docker.images.list.return_value = [image_new, image_old]
|
||||
|
||||
with patch.object(DockerAPI, "pull_image", return_value=image_new):
|
||||
await coresys.addons.update("local_example2")
|
||||
docker.images.remove.assert_not_called()
|
||||
docker.images.delete.assert_not_called()
|
||||
assert example_2.version == "1.3.0"
|
||||
|
||||
docker.images.get.side_effect = [image_new]
|
||||
docker.images.inspect.side_effect = [image_new]
|
||||
await coresys.addons.update("local_example_image")
|
||||
docker.images.remove.assert_called_once_with("image_old", force=True)
|
||||
docker.images.delete.assert_called_once_with("image_old", force=True)
|
||||
assert install_addon_example_image.version == "1.3.0"
|
||||
|
||||
@@ -482,6 +482,11 @@ 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:
|
||||
@@ -550,3 +555,94 @@ 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 with addon local_example. Check supervisor logs for details (check with 'ha supervisor logs')"
|
||||
)
|
||||
assert body["error_key"] == "addon_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"}
|
||||
|
||||
@@ -6,9 +6,11 @@ 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
|
||||
@@ -100,6 +102,52 @@ 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
|
||||
):
|
||||
@@ -120,6 +168,48 @@ 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)],
|
||||
|
||||
@@ -17,6 +17,7 @@ from supervisor.const import CoreState
|
||||
from supervisor.coresys import CoreSys
|
||||
from supervisor.docker.manager import DockerAPI
|
||||
from supervisor.exceptions import (
|
||||
AddonPrePostBackupCommandReturnedError,
|
||||
AddonsError,
|
||||
BackupInvalidError,
|
||||
HomeAssistantBackupError,
|
||||
@@ -24,6 +25,7 @@ from supervisor.exceptions import (
|
||||
from supervisor.homeassistant.core import HomeAssistantCore
|
||||
from supervisor.homeassistant.module import HomeAssistant
|
||||
from supervisor.homeassistant.websocket import HomeAssistantWebSocket
|
||||
from supervisor.jobs import SupervisorJob
|
||||
from supervisor.mounts.mount import Mount
|
||||
from supervisor.supervisor import Supervisor
|
||||
|
||||
@@ -401,6 +403,8 @@ async def test_api_backup_errors(
|
||||
"type": "BackupError",
|
||||
"message": str(err),
|
||||
"stage": None,
|
||||
"error_key": None,
|
||||
"extra_fields": None,
|
||||
}
|
||||
]
|
||||
assert job["child_jobs"][2]["name"] == "backup_store_folders"
|
||||
@@ -437,6 +441,8 @@ async def test_api_backup_errors(
|
||||
"type": "HomeAssistantBackupError",
|
||||
"message": "Backup error",
|
||||
"stage": "home_assistant",
|
||||
"error_key": None,
|
||||
"extra_fields": None,
|
||||
}
|
||||
]
|
||||
assert job["child_jobs"][0]["name"] == "backup_store_homeassistant"
|
||||
@@ -445,6 +451,8 @@ async def test_api_backup_errors(
|
||||
"type": "HomeAssistantBackupError",
|
||||
"message": "Backup error",
|
||||
"stage": None,
|
||||
"error_key": None,
|
||||
"extra_fields": None,
|
||||
}
|
||||
]
|
||||
assert len(job["child_jobs"]) == 1
|
||||
@@ -749,6 +757,8 @@ async def test_backup_to_multiple_locations_error_on_copy(
|
||||
"type": "BackupError",
|
||||
"message": "Could not copy backup to .cloud_backup due to: ",
|
||||
"stage": None,
|
||||
"error_key": None,
|
||||
"extra_fields": None,
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1483,3 +1493,44 @@ async def test_immediate_list_after_missing_file_restore(
|
||||
result = await resp.json()
|
||||
assert len(result["data"]["backups"]) == 1
|
||||
assert result["data"]["backups"][0]["slug"] == "93b462f8"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command", ["backup_pre", "backup_post"])
|
||||
@pytest.mark.usefixtures("install_addon_example", "tmp_supervisor_data")
|
||||
async def test_pre_post_backup_command_error(
|
||||
api_client: TestClient, coresys: CoreSys, container: MagicMock, command: str
|
||||
):
|
||||
"""Test pre/post backup command error."""
|
||||
await coresys.core.set_state(CoreState.RUNNING)
|
||||
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
||||
|
||||
container.status = "running"
|
||||
container.exec_run.return_value = (1, b"")
|
||||
with patch.object(Addon, command, return_value=PropertyMock(return_value="test")):
|
||||
resp = await api_client.post(
|
||||
"/backups/new/partial", json={"addons": ["local_example"]}
|
||||
)
|
||||
|
||||
assert resp.status == 200
|
||||
body = await resp.json()
|
||||
job_id = body["data"]["job_id"]
|
||||
job: SupervisorJob | None = None
|
||||
for j in coresys.jobs.jobs:
|
||||
if j.name == "backup_store_addons" and j.parent_id == job_id:
|
||||
job = j
|
||||
break
|
||||
|
||||
assert job
|
||||
assert job.done is True
|
||||
assert job.errors[0].type_ == AddonPrePostBackupCommandReturnedError
|
||||
assert job.errors[0].message == (
|
||||
"Pre-/Post backup command for add-on local_example returned error code: "
|
||||
"1. Please report this to the addon developer. Enable debug "
|
||||
"logging to capture complete command output using ha supervisor options --logging debug"
|
||||
)
|
||||
assert job.errors[0].error_key == "addon_pre_post_backup_command_returned_error"
|
||||
assert job.errors[0].extra_fields == {
|
||||
"addon": "local_example",
|
||||
"exit_code": 1,
|
||||
"debug_logging_command": "ha supervisor options --logging debug",
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ from supervisor.homeassistant.core import HomeAssistantCore
|
||||
from supervisor.homeassistant.module import HomeAssistant
|
||||
|
||||
from tests.api import common_test_api_advanced_logs
|
||||
from tests.common import load_json_fixture
|
||||
from tests.common import AsyncIterator, load_json_fixture
|
||||
|
||||
|
||||
@pytest.mark.parametrize("legacy_route", [True, False])
|
||||
@@ -283,9 +283,9 @@ async def test_api_progress_updates_home_assistant_update(
|
||||
"""Test progress updates sent to Home Assistant for updates."""
|
||||
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
||||
coresys.core.set_state(CoreState.RUNNING)
|
||||
coresys.docker.docker.api.pull.return_value = load_json_fixture(
|
||||
"docker_pull_image_log.json"
|
||||
)
|
||||
|
||||
logs = load_json_fixture("docker_pull_image_log.json")
|
||||
coresys.docker.images.pull.return_value = AsyncIterator(logs)
|
||||
coresys.homeassistant.version = AwesomeVersion("2025.8.0")
|
||||
|
||||
with (
|
||||
|
||||
@@ -374,6 +374,8 @@ async def test_job_with_error(
|
||||
"type": "SupervisorError",
|
||||
"message": "bad",
|
||||
"stage": "test",
|
||||
"error_key": None,
|
||||
"extra_fields": None,
|
||||
}
|
||||
],
|
||||
"child_jobs": [
|
||||
@@ -391,6 +393,8 @@ async def test_job_with_error(
|
||||
"type": "SupervisorError",
|
||||
"message": "bad",
|
||||
"stage": None,
|
||||
"error_key": None,
|
||||
"extra_fields": None,
|
||||
}
|
||||
],
|
||||
"child_jobs": [],
|
||||
|
||||
@@ -24,7 +24,7 @@ from supervisor.homeassistant.module import HomeAssistant
|
||||
from supervisor.store.addon import AddonStore
|
||||
from supervisor.store.repository import Repository
|
||||
|
||||
from tests.common import load_json_fixture
|
||||
from tests.common import AsyncIterator, load_json_fixture
|
||||
from tests.const import TEST_ADDON_SLUG
|
||||
|
||||
REPO_URL = "https://github.com/awesome-developer/awesome-repo"
|
||||
@@ -323,7 +323,10 @@ 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"
|
||||
assert (
|
||||
await get_message(resp, json_expected)
|
||||
== "Addon bad does not exist in the store"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -732,9 +735,10 @@ async def test_api_progress_updates_addon_install_update(
|
||||
"""Test progress updates sent to Home Assistant for installs/updates."""
|
||||
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
||||
coresys.core.set_state(CoreState.RUNNING)
|
||||
coresys.docker.docker.api.pull.return_value = load_json_fixture(
|
||||
"docker_pull_image_log.json"
|
||||
)
|
||||
|
||||
logs = load_json_fixture("docker_pull_image_log.json")
|
||||
coresys.docker.images.pull.return_value = AsyncIterator(logs)
|
||||
|
||||
coresys.arch._supported_arch = ["amd64"] # pylint: disable=protected-access
|
||||
install_addon_example.data_store["version"] = AwesomeVersion("2.0.0")
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -19,7 +20,7 @@ from supervisor.supervisor import Supervisor
|
||||
from supervisor.updater import Updater
|
||||
|
||||
from tests.api import common_test_api_advanced_logs
|
||||
from tests.common import load_json_fixture
|
||||
from tests.common import AsyncIterator, load_json_fixture
|
||||
from tests.dbus_service_mocks.base import DBusServiceMock
|
||||
from tests.dbus_service_mocks.os_agent import OSAgent as OSAgentService
|
||||
|
||||
@@ -332,9 +333,9 @@ async def test_api_progress_updates_supervisor_update(
|
||||
"""Test progress updates sent to Home Assistant for updates."""
|
||||
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
||||
coresys.core.set_state(CoreState.RUNNING)
|
||||
coresys.docker.docker.api.pull.return_value = load_json_fixture(
|
||||
"docker_pull_image_log.json"
|
||||
)
|
||||
|
||||
logs = load_json_fixture("docker_pull_image_log.json")
|
||||
coresys.docker.images.pull.return_value = AsyncIterator(logs)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
@@ -417,3 +418,37 @@ 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
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"""Common test functions."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
from importlib import import_module
|
||||
from inspect import getclosurevars
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Self
|
||||
|
||||
from dbus_fast.aio.message_bus import MessageBus
|
||||
|
||||
@@ -145,3 +146,22 @@ class MockResponse:
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
"""Exit the context manager."""
|
||||
|
||||
|
||||
class AsyncIterator:
|
||||
"""Make list/fixture into async iterator for test mocks."""
|
||||
|
||||
def __init__(self, seq: Sequence[Any]) -> None:
|
||||
"""Initialize with sequence."""
|
||||
self.iter = iter(seq)
|
||||
|
||||
def __aiter__(self) -> Self:
|
||||
"""Implement aiter."""
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> Any:
|
||||
"""Return next in sequence."""
|
||||
try:
|
||||
return next(self.iter)
|
||||
except StopIteration:
|
||||
raise StopAsyncIteration() from None
|
||||
|
||||
@@ -9,6 +9,7 @@ import subprocess
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
from aiodocker.docker import DockerImages
|
||||
from aiohttp import ClientSession, web
|
||||
from aiohttp.test_utils import TestClient
|
||||
from awesomeversion import AwesomeVersion
|
||||
@@ -55,6 +56,7 @@ from supervisor.store.repository import Repository
|
||||
from supervisor.utils.dt import utcnow
|
||||
|
||||
from .common import (
|
||||
AsyncIterator,
|
||||
MockResponse,
|
||||
load_binary_fixture,
|
||||
load_fixture,
|
||||
@@ -112,40 +114,46 @@ async def supervisor_name() -> None:
|
||||
@pytest.fixture
|
||||
async def docker() -> DockerAPI:
|
||||
"""Mock DockerAPI."""
|
||||
images = [MagicMock(tags=["ghcr.io/home-assistant/amd64-hassio-supervisor:latest"])]
|
||||
image = MagicMock()
|
||||
image.attrs = {"Os": "linux", "Architecture": "amd64"}
|
||||
image_inspect = {
|
||||
"Os": "linux",
|
||||
"Architecture": "amd64",
|
||||
"Id": "test123",
|
||||
"RepoTags": ["ghcr.io/home-assistant/amd64-hassio-supervisor:latest"],
|
||||
}
|
||||
|
||||
with (
|
||||
patch("supervisor.docker.manager.DockerClient", return_value=MagicMock()),
|
||||
patch("supervisor.docker.manager.DockerAPI.images", return_value=MagicMock()),
|
||||
patch(
|
||||
"supervisor.docker.manager.DockerAPI.containers", return_value=MagicMock()
|
||||
),
|
||||
patch(
|
||||
"supervisor.docker.manager.DockerAPI.api",
|
||||
return_value=(api_mock := MagicMock()),
|
||||
),
|
||||
patch("supervisor.docker.manager.DockerAPI.images.get", return_value=image),
|
||||
patch("supervisor.docker.manager.DockerAPI.images.list", return_value=images),
|
||||
patch(
|
||||
"supervisor.docker.manager.DockerAPI.info",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch("supervisor.docker.manager.DockerAPI.api", return_value=MagicMock()),
|
||||
patch("supervisor.docker.manager.DockerAPI.info", return_value=MagicMock()),
|
||||
patch("supervisor.docker.manager.DockerAPI.unload"),
|
||||
patch("supervisor.docker.manager.aiodocker.Docker", return_value=MagicMock()),
|
||||
patch(
|
||||
"supervisor.docker.manager.DockerAPI.images",
|
||||
new=PropertyMock(
|
||||
return_value=(docker_images := MagicMock(spec=DockerImages))
|
||||
),
|
||||
),
|
||||
):
|
||||
docker_obj = await DockerAPI(MagicMock()).post_init()
|
||||
docker_obj.config._data = {"registries": {}}
|
||||
with patch("supervisor.docker.monitor.DockerMonitor.load"):
|
||||
await docker_obj.load()
|
||||
|
||||
docker_images.inspect.return_value = image_inspect
|
||||
docker_images.list.return_value = [image_inspect]
|
||||
docker_images.import_image.return_value = [
|
||||
{"stream": "Loaded image: test:latest\n"}
|
||||
]
|
||||
|
||||
docker_images.pull.return_value = AsyncIterator([{}])
|
||||
|
||||
docker_obj.info.logging = "journald"
|
||||
docker_obj.info.storage = "overlay2"
|
||||
docker_obj.info.version = AwesomeVersion("1.0.0")
|
||||
|
||||
# Need an iterable for logs
|
||||
api_mock.pull.return_value = []
|
||||
|
||||
yield docker_obj
|
||||
|
||||
|
||||
@@ -838,11 +846,9 @@ async def container(docker: DockerAPI) -> MagicMock:
|
||||
"""Mock attrs and status for container on attach."""
|
||||
docker.containers.get.return_value = addon = MagicMock()
|
||||
docker.containers.create.return_value = addon
|
||||
docker.images.build.return_value = (addon, "")
|
||||
addon.status = "stopped"
|
||||
addon.attrs = {"State": {"ExitCode": 0}}
|
||||
with patch.object(DockerAPI, "pull_image", return_value=addon):
|
||||
yield addon
|
||||
yield addon
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -5,10 +5,10 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import ANY, AsyncMock, MagicMock, Mock, PropertyMock, call, patch
|
||||
|
||||
import aiodocker
|
||||
from awesomeversion import AwesomeVersion
|
||||
from docker.errors import DockerException, NotFound
|
||||
from docker.models.containers import Container
|
||||
from docker.models.images import Image
|
||||
import pytest
|
||||
from requests import RequestException
|
||||
|
||||
@@ -28,7 +28,7 @@ from supervisor.exceptions import (
|
||||
)
|
||||
from supervisor.jobs import JobSchedulerOptions, SupervisorJob
|
||||
|
||||
from tests.common import load_json_fixture
|
||||
from tests.common import AsyncIterator, load_json_fixture
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -48,35 +48,30 @@ async def test_docker_image_platform(
|
||||
platform: str,
|
||||
):
|
||||
"""Test platform set correctly from arch."""
|
||||
with patch.object(
|
||||
coresys.docker.images, "get", return_value=Mock(id="test:1.2.3")
|
||||
) as get:
|
||||
await test_docker_interface.install(
|
||||
AwesomeVersion("1.2.3"), "test", arch=cpu_arch
|
||||
)
|
||||
coresys.docker.docker.api.pull.assert_called_once_with(
|
||||
"test", tag="1.2.3", platform=platform, stream=True, decode=True
|
||||
)
|
||||
get.assert_called_once_with("test:1.2.3")
|
||||
coresys.docker.images.inspect.return_value = {"Id": "test:1.2.3"}
|
||||
await test_docker_interface.install(AwesomeVersion("1.2.3"), "test", arch=cpu_arch)
|
||||
coresys.docker.images.pull.assert_called_once_with(
|
||||
"test", tag="1.2.3", platform=platform, stream=True
|
||||
)
|
||||
coresys.docker.images.inspect.assert_called_once_with("test:1.2.3")
|
||||
|
||||
|
||||
async def test_docker_image_default_platform(
|
||||
coresys: CoreSys, test_docker_interface: DockerInterface
|
||||
):
|
||||
"""Test platform set using supervisor arch when omitted."""
|
||||
coresys.docker.images.inspect.return_value = {"Id": "test:1.2.3"}
|
||||
with (
|
||||
patch.object(
|
||||
type(coresys.supervisor), "arch", PropertyMock(return_value="i386")
|
||||
),
|
||||
patch.object(
|
||||
coresys.docker.images, "get", return_value=Mock(id="test:1.2.3")
|
||||
) as get,
|
||||
):
|
||||
await test_docker_interface.install(AwesomeVersion("1.2.3"), "test")
|
||||
coresys.docker.docker.api.pull.assert_called_once_with(
|
||||
"test", tag="1.2.3", platform="linux/386", stream=True, decode=True
|
||||
coresys.docker.images.pull.assert_called_once_with(
|
||||
"test", tag="1.2.3", platform="linux/386", stream=True
|
||||
)
|
||||
get.assert_called_once_with("test:1.2.3")
|
||||
|
||||
coresys.docker.images.inspect.assert_called_once_with("test:1.2.3")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -207,57 +202,40 @@ async def test_attach_existing_container(
|
||||
|
||||
async def test_attach_container_failure(coresys: CoreSys):
|
||||
"""Test attach fails to find container but finds image."""
|
||||
container_collection = MagicMock()
|
||||
container_collection.get.side_effect = DockerException()
|
||||
image_collection = MagicMock()
|
||||
image_config = {"Image": "sha256:abc123"}
|
||||
image_collection.get.return_value = Image({"Config": image_config})
|
||||
with (
|
||||
patch(
|
||||
"supervisor.docker.manager.DockerAPI.containers",
|
||||
new=PropertyMock(return_value=container_collection),
|
||||
),
|
||||
patch(
|
||||
"supervisor.docker.manager.DockerAPI.images",
|
||||
new=PropertyMock(return_value=image_collection),
|
||||
),
|
||||
patch.object(type(coresys.bus), "fire_event") as fire_event,
|
||||
):
|
||||
coresys.docker.containers.get.side_effect = DockerException()
|
||||
coresys.docker.images.inspect.return_value.setdefault("Config", {})["Image"] = (
|
||||
"sha256:abc123"
|
||||
)
|
||||
with patch.object(type(coresys.bus), "fire_event") as fire_event:
|
||||
await coresys.homeassistant.core.instance.attach(AwesomeVersion("2022.7.3"))
|
||||
assert not [
|
||||
event
|
||||
for event in fire_event.call_args_list
|
||||
if event.args[0] == BusEvent.DOCKER_CONTAINER_STATE_CHANGE
|
||||
]
|
||||
assert coresys.homeassistant.core.instance.meta_config == image_config
|
||||
assert (
|
||||
coresys.homeassistant.core.instance.meta_config["Image"] == "sha256:abc123"
|
||||
)
|
||||
|
||||
|
||||
async def test_attach_total_failure(coresys: CoreSys):
|
||||
"""Test attach fails to find container or image."""
|
||||
container_collection = MagicMock()
|
||||
container_collection.get.side_effect = DockerException()
|
||||
image_collection = MagicMock()
|
||||
image_collection.get.side_effect = DockerException()
|
||||
with (
|
||||
patch(
|
||||
"supervisor.docker.manager.DockerAPI.containers",
|
||||
new=PropertyMock(return_value=container_collection),
|
||||
),
|
||||
patch(
|
||||
"supervisor.docker.manager.DockerAPI.images",
|
||||
new=PropertyMock(return_value=image_collection),
|
||||
),
|
||||
pytest.raises(DockerError),
|
||||
):
|
||||
coresys.docker.containers.get.side_effect = DockerException
|
||||
coresys.docker.images.inspect.side_effect = aiodocker.DockerError(
|
||||
400, {"message": ""}
|
||||
)
|
||||
with pytest.raises(DockerError):
|
||||
await coresys.homeassistant.core.instance.attach(AwesomeVersion("2022.7.3"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("err", [DockerException(), RequestException()])
|
||||
@pytest.mark.parametrize(
|
||||
"err", [aiodocker.DockerError(400, {"message": ""}), RequestException()]
|
||||
)
|
||||
async def test_image_pull_fail(
|
||||
coresys: CoreSys, capture_exception: Mock, err: Exception
|
||||
):
|
||||
"""Test failure to pull image."""
|
||||
coresys.docker.images.get.side_effect = err
|
||||
coresys.docker.images.inspect.side_effect = err
|
||||
with pytest.raises(DockerError):
|
||||
await coresys.homeassistant.core.instance.install(
|
||||
AwesomeVersion("2022.7.3"), arch=CpuArch.AMD64
|
||||
@@ -289,8 +267,9 @@ async def test_install_fires_progress_events(
|
||||
coresys: CoreSys, test_docker_interface: DockerInterface
|
||||
):
|
||||
"""Test progress events are fired during an install for listeners."""
|
||||
|
||||
# This is from a sample pull. Filtered log to just one per unique status for test
|
||||
coresys.docker.docker.api.pull.return_value = [
|
||||
logs = [
|
||||
{
|
||||
"status": "Pulling from home-assistant/odroid-n2-homeassistant",
|
||||
"id": "2025.7.2",
|
||||
@@ -312,7 +291,11 @@ async def test_install_fires_progress_events(
|
||||
"id": "1578b14a573c",
|
||||
},
|
||||
{"status": "Pull complete", "progressDetail": {}, "id": "1578b14a573c"},
|
||||
{"status": "Verifying Checksum", "progressDetail": {}, "id": "6a1e931d8f88"},
|
||||
{
|
||||
"status": "Verifying Checksum",
|
||||
"progressDetail": {},
|
||||
"id": "6a1e931d8f88",
|
||||
},
|
||||
{
|
||||
"status": "Digest: sha256:490080d7da0f385928022927990e04f604615f7b8c622ef3e58253d0f089881d"
|
||||
},
|
||||
@@ -320,6 +303,7 @@ async def test_install_fires_progress_events(
|
||||
"status": "Status: Downloaded newer image for ghcr.io/home-assistant/odroid-n2-homeassistant:2025.7.2"
|
||||
},
|
||||
]
|
||||
coresys.docker.images.pull.return_value = AsyncIterator(logs)
|
||||
|
||||
events: list[PullLogEntry] = []
|
||||
|
||||
@@ -334,10 +318,10 @@ async def test_install_fires_progress_events(
|
||||
),
|
||||
):
|
||||
await test_docker_interface.install(AwesomeVersion("1.2.3"), "test")
|
||||
coresys.docker.docker.api.pull.assert_called_once_with(
|
||||
"test", tag="1.2.3", platform="linux/386", stream=True, decode=True
|
||||
coresys.docker.images.pull.assert_called_once_with(
|
||||
"test", tag="1.2.3", platform="linux/386", stream=True
|
||||
)
|
||||
coresys.docker.images.get.assert_called_once_with("test:1.2.3")
|
||||
coresys.docker.images.inspect.assert_called_once_with("test:1.2.3")
|
||||
|
||||
await asyncio.sleep(1)
|
||||
assert events == [
|
||||
@@ -415,10 +399,11 @@ async def test_install_progress_rounding_does_not_cause_misses(
|
||||
):
|
||||
"""Test extremely close progress events do not create rounding issues."""
|
||||
coresys.core.set_state(CoreState.RUNNING)
|
||||
|
||||
# Current numbers chosen to create a rounding issue with original code
|
||||
# Where a progress update came in with a value between the actual previous
|
||||
# value and what it was rounded to. It should not raise an out of order exception
|
||||
coresys.docker.docker.api.pull.return_value = [
|
||||
logs = [
|
||||
{
|
||||
"status": "Pulling from home-assistant/odroid-n2-homeassistant",
|
||||
"id": "2025.7.1",
|
||||
@@ -458,6 +443,7 @@ async def test_install_progress_rounding_does_not_cause_misses(
|
||||
"status": "Status: Downloaded newer image for ghcr.io/home-assistant/odroid-n2-homeassistant:2025.7.1"
|
||||
},
|
||||
]
|
||||
coresys.docker.images.pull.return_value = AsyncIterator(logs)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
@@ -513,7 +499,8 @@ async def test_install_raises_on_pull_error(
|
||||
exc_msg: str,
|
||||
):
|
||||
"""Test exceptions raised from errors in pull log."""
|
||||
coresys.docker.docker.api.pull.return_value = [
|
||||
|
||||
logs = [
|
||||
{
|
||||
"status": "Pulling from home-assistant/odroid-n2-homeassistant",
|
||||
"id": "2025.7.2",
|
||||
@@ -526,6 +513,7 @@ async def test_install_raises_on_pull_error(
|
||||
},
|
||||
error_log,
|
||||
]
|
||||
coresys.docker.images.pull.return_value = AsyncIterator(logs)
|
||||
|
||||
with pytest.raises(exc_type, match=exc_msg):
|
||||
await test_docker_interface.install(AwesomeVersion("1.2.3"), "test")
|
||||
@@ -539,11 +527,11 @@ async def test_install_progress_handles_download_restart(
|
||||
):
|
||||
"""Test install handles docker progress events that include a download restart."""
|
||||
coresys.core.set_state(CoreState.RUNNING)
|
||||
|
||||
# Fixture emulates a download restart as it docker logs it
|
||||
# A log out of order exception should not be raised
|
||||
coresys.docker.docker.api.pull.return_value = load_json_fixture(
|
||||
"docker_pull_image_log_restart.json"
|
||||
)
|
||||
logs = load_json_fixture("docker_pull_image_log_restart.json")
|
||||
coresys.docker.images.pull.return_value = AsyncIterator(logs)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
@@ -586,7 +574,7 @@ async def test_install_progress_handles_layers_skipping_download(
|
||||
|
||||
# Reproduce EXACT sequence from SupervisorNoUpdateProgressLogs.txt:
|
||||
# Small layer (02a6e69d8d00) completes BEFORE normal layer (3f4a84073184) starts downloading
|
||||
coresys.docker.docker.api.pull.return_value = [
|
||||
logs = [
|
||||
{"status": "Pulling from test/image", "id": "latest"},
|
||||
# Small layer that skips downloading (02a6e69d8d00 in logs, 96 bytes)
|
||||
{"status": "Pulling fs layer", "progressDetail": {}, "id": "02a6e69d8d00"},
|
||||
@@ -634,6 +622,7 @@ async def test_install_progress_handles_layers_skipping_download(
|
||||
{"status": "Digest: sha256:test"},
|
||||
{"status": "Status: Downloaded newer image for test/image:latest"},
|
||||
]
|
||||
coresys.docker.images.pull.return_value = AsyncIterator(logs)
|
||||
|
||||
# Capture immutable snapshots of install job progress using job.as_dict()
|
||||
# This solves the mutable object problem - we snapshot state at call time
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Test Docker manager."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from docker.errors import DockerException
|
||||
from docker.errors import APIError, DockerException, NotFound
|
||||
import pytest
|
||||
from requests import RequestException
|
||||
|
||||
@@ -20,7 +21,7 @@ async def test_run_command_success(docker: DockerAPI):
|
||||
mock_container.logs.return_value = b"command output"
|
||||
|
||||
# Mock docker containers.run to return our mock container
|
||||
docker.docker.containers.run.return_value = mock_container
|
||||
docker.dockerpy.containers.run.return_value = mock_container
|
||||
|
||||
# Execute the command
|
||||
result = docker.run_command(
|
||||
@@ -33,7 +34,7 @@ async def test_run_command_success(docker: DockerAPI):
|
||||
assert result.output == b"command output"
|
||||
|
||||
# Verify docker.containers.run was called correctly
|
||||
docker.docker.containers.run.assert_called_once_with(
|
||||
docker.dockerpy.containers.run.assert_called_once_with(
|
||||
"alpine:3.18",
|
||||
command="echo hello",
|
||||
detach=True,
|
||||
@@ -55,7 +56,7 @@ async def test_run_command_with_defaults(docker: DockerAPI):
|
||||
mock_container.logs.return_value = b"error output"
|
||||
|
||||
# Mock docker containers.run to return our mock container
|
||||
docker.docker.containers.run.return_value = mock_container
|
||||
docker.dockerpy.containers.run.return_value = mock_container
|
||||
|
||||
# Execute the command with minimal parameters
|
||||
result = docker.run_command(image="ubuntu")
|
||||
@@ -66,7 +67,7 @@ async def test_run_command_with_defaults(docker: DockerAPI):
|
||||
assert result.output == b"error output"
|
||||
|
||||
# Verify docker.containers.run was called with defaults
|
||||
docker.docker.containers.run.assert_called_once_with(
|
||||
docker.dockerpy.containers.run.assert_called_once_with(
|
||||
"ubuntu:latest", # default tag
|
||||
command=None, # default command
|
||||
detach=True,
|
||||
@@ -81,7 +82,7 @@ async def test_run_command_with_defaults(docker: DockerAPI):
|
||||
async def test_run_command_docker_exception(docker: DockerAPI):
|
||||
"""Test command execution when Docker raises an exception."""
|
||||
# Mock docker containers.run to raise DockerException
|
||||
docker.docker.containers.run.side_effect = DockerException("Docker error")
|
||||
docker.dockerpy.containers.run.side_effect = DockerException("Docker error")
|
||||
|
||||
# Execute the command and expect DockerError
|
||||
with pytest.raises(DockerError, match="Can't execute command: Docker error"):
|
||||
@@ -91,7 +92,7 @@ async def test_run_command_docker_exception(docker: DockerAPI):
|
||||
async def test_run_command_request_exception(docker: DockerAPI):
|
||||
"""Test command execution when requests raises an exception."""
|
||||
# Mock docker containers.run to raise RequestException
|
||||
docker.docker.containers.run.side_effect = RequestException("Connection error")
|
||||
docker.dockerpy.containers.run.side_effect = RequestException("Connection error")
|
||||
|
||||
# Execute the command and expect DockerError
|
||||
with pytest.raises(DockerError, match="Can't execute command: Connection error"):
|
||||
@@ -104,7 +105,7 @@ async def test_run_command_cleanup_on_exception(docker: DockerAPI):
|
||||
mock_container = MagicMock()
|
||||
|
||||
# Mock docker.containers.run to return container, but container.wait to raise exception
|
||||
docker.docker.containers.run.return_value = mock_container
|
||||
docker.dockerpy.containers.run.return_value = mock_container
|
||||
mock_container.wait.side_effect = DockerException("Wait failed")
|
||||
|
||||
# Execute the command and expect DockerError
|
||||
@@ -123,7 +124,7 @@ async def test_run_command_custom_stdout_stderr(docker: DockerAPI):
|
||||
mock_container.logs.return_value = b"output"
|
||||
|
||||
# Mock docker containers.run to return our mock container
|
||||
docker.docker.containers.run.return_value = mock_container
|
||||
docker.dockerpy.containers.run.return_value = mock_container
|
||||
|
||||
# Execute the command with custom stdout/stderr
|
||||
result = docker.run_command(
|
||||
@@ -150,7 +151,7 @@ async def test_run_container_with_cidfile(
|
||||
cidfile_path = coresys.config.path_cid_files / f"{container_name}.cid"
|
||||
extern_cidfile_path = coresys.config.path_extern_cid_files / f"{container_name}.cid"
|
||||
|
||||
docker.docker.containers.run.return_value = mock_container
|
||||
docker.dockerpy.containers.run.return_value = mock_container
|
||||
|
||||
# Mock container creation
|
||||
with patch.object(
|
||||
@@ -351,3 +352,101 @@ async def test_run_container_with_leftover_cidfile_directory(
|
||||
assert cidfile_path.read_text() == mock_container.id
|
||||
|
||||
assert result == mock_container
|
||||
|
||||
|
||||
async def test_repair(coresys: CoreSys, caplog: pytest.LogCaptureFixture):
|
||||
"""Test repair API."""
|
||||
coresys.docker.dockerpy.networks.get.side_effect = [
|
||||
hassio := MagicMock(
|
||||
attrs={
|
||||
"Containers": {
|
||||
"good": {"Name": "good"},
|
||||
"corrupt": {"Name": "corrupt"},
|
||||
"fail": {"Name": "fail"},
|
||||
}
|
||||
}
|
||||
),
|
||||
host := MagicMock(attrs={"Containers": {}}),
|
||||
]
|
||||
coresys.docker.dockerpy.containers.get.side_effect = [
|
||||
MagicMock(),
|
||||
NotFound("corrupt"),
|
||||
DockerException("fail"),
|
||||
]
|
||||
|
||||
await coresys.run_in_executor(coresys.docker.repair)
|
||||
|
||||
coresys.docker.dockerpy.api.prune_containers.assert_called_once()
|
||||
coresys.docker.dockerpy.api.prune_images.assert_called_once_with(
|
||||
filters={"dangling": False}
|
||||
)
|
||||
coresys.docker.dockerpy.api.prune_builds.assert_called_once()
|
||||
coresys.docker.dockerpy.api.prune_volumes.assert_called_once()
|
||||
coresys.docker.dockerpy.api.prune_networks.assert_called_once()
|
||||
hassio.disconnect.assert_called_once_with("corrupt", force=True)
|
||||
host.disconnect.assert_not_called()
|
||||
assert "Docker fatal error on container fail on hassio" in caplog.text
|
||||
|
||||
|
||||
async def test_repair_failures(coresys: CoreSys, caplog: pytest.LogCaptureFixture):
|
||||
"""Test repair proceeds best it can through failures."""
|
||||
coresys.docker.dockerpy.api.prune_containers.side_effect = APIError("fail")
|
||||
coresys.docker.dockerpy.api.prune_images.side_effect = APIError("fail")
|
||||
coresys.docker.dockerpy.api.prune_builds.side_effect = APIError("fail")
|
||||
coresys.docker.dockerpy.api.prune_volumes.side_effect = APIError("fail")
|
||||
coresys.docker.dockerpy.api.prune_networks.side_effect = APIError("fail")
|
||||
coresys.docker.dockerpy.networks.get.side_effect = NotFound("missing")
|
||||
|
||||
await coresys.run_in_executor(coresys.docker.repair)
|
||||
|
||||
assert "Error for containers prune: fail" in caplog.text
|
||||
assert "Error for images prune: fail" in caplog.text
|
||||
assert "Error for builds prune: fail" in caplog.text
|
||||
assert "Error for volumes prune: fail" in caplog.text
|
||||
assert "Error for networks prune: fail" in caplog.text
|
||||
assert "Error for networks hassio prune: missing" in caplog.text
|
||||
assert "Error for networks host prune: missing" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("log_starter", [("Loaded image ID"), ("Loaded image")])
|
||||
async def test_import_image(coresys: CoreSys, tmp_path: Path, log_starter: str):
|
||||
"""Test importing an image into docker."""
|
||||
(test_tar := tmp_path / "test.tar").touch()
|
||||
coresys.docker.images.import_image.return_value = [
|
||||
{"stream": f"{log_starter}: imported"}
|
||||
]
|
||||
coresys.docker.images.inspect.return_value = {"Id": "imported"}
|
||||
|
||||
image = await coresys.docker.import_image(test_tar)
|
||||
|
||||
assert image["Id"] == "imported"
|
||||
coresys.docker.images.inspect.assert_called_once_with("imported")
|
||||
|
||||
|
||||
async def test_import_image_error(coresys: CoreSys, tmp_path: Path):
|
||||
"""Test failure importing an image into docker."""
|
||||
(test_tar := tmp_path / "test.tar").touch()
|
||||
coresys.docker.images.import_image.return_value = [
|
||||
{"errorDetail": {"message": "fail"}}
|
||||
]
|
||||
|
||||
with pytest.raises(DockerError, match="Can't import image from tar: fail"):
|
||||
await coresys.docker.import_image(test_tar)
|
||||
|
||||
coresys.docker.images.inspect.assert_not_called()
|
||||
|
||||
|
||||
async def test_import_multiple_images_in_tar(
|
||||
coresys: CoreSys, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
):
|
||||
"""Test importing an image into docker."""
|
||||
(test_tar := tmp_path / "test.tar").touch()
|
||||
coresys.docker.images.import_image.return_value = [
|
||||
{"stream": "Loaded image: imported-1"},
|
||||
{"stream": "Loaded image: imported-2"},
|
||||
]
|
||||
|
||||
assert await coresys.docker.import_image(test_tar) is None
|
||||
|
||||
assert "Unexpected image count 2 while importing image from tar" in caplog.text
|
||||
coresys.docker.images.inspect.assert_not_called()
|
||||
|
||||
@@ -88,7 +88,7 @@ async def test_events(
|
||||
):
|
||||
"""Test events created from docker events."""
|
||||
event["Actor"]["Attributes"]["name"] = "some_container"
|
||||
event["id"] = "abc123"
|
||||
event["Actor"]["ID"] = "abc123"
|
||||
event["time"] = 123
|
||||
with (
|
||||
patch(
|
||||
@@ -131,12 +131,12 @@ async def test_unlabeled_container(coresys: CoreSys):
|
||||
new=PropertyMock(
|
||||
return_value=[
|
||||
{
|
||||
"id": "abc123",
|
||||
"time": 123,
|
||||
"Type": "container",
|
||||
"Action": "die",
|
||||
"Actor": {
|
||||
"Attributes": {"name": "homeassistant", "exitCode": "137"}
|
||||
"ID": "abc123",
|
||||
"Attributes": {"name": "homeassistant", "exitCode": "137"},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""Test Home Assistant core."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import ANY, MagicMock, Mock, PropertyMock, patch
|
||||
from http import HTTPStatus
|
||||
from unittest.mock import ANY, MagicMock, Mock, PropertyMock, call, patch
|
||||
|
||||
import aiodocker
|
||||
from awesomeversion import AwesomeVersion
|
||||
from docker.errors import APIError, DockerException, ImageNotFound, NotFound
|
||||
from docker.errors import APIError, DockerException, NotFound
|
||||
import pytest
|
||||
from requests import RequestException
|
||||
from time_machine import travel
|
||||
|
||||
from supervisor.const import CpuArch
|
||||
@@ -23,8 +26,12 @@ from supervisor.exceptions import (
|
||||
from supervisor.homeassistant.api import APIState
|
||||
from supervisor.homeassistant.core import HomeAssistantCore
|
||||
from supervisor.homeassistant.module import HomeAssistant
|
||||
from supervisor.resolution.const import ContextType, IssueType
|
||||
from supervisor.resolution.data import Issue
|
||||
from supervisor.updater import Updater
|
||||
|
||||
from tests.common import AsyncIterator
|
||||
|
||||
|
||||
async def test_update_fails_if_out_of_date(coresys: CoreSys):
|
||||
"""Test update of Home Assistant fails when supervisor or plugin is out of date."""
|
||||
@@ -52,11 +59,23 @@ async def test_update_fails_if_out_of_date(coresys: CoreSys):
|
||||
await coresys.homeassistant.core.update()
|
||||
|
||||
|
||||
async def test_install_landingpage_docker_error(
|
||||
coresys: CoreSys, capture_exception: Mock, caplog: pytest.LogCaptureFixture
|
||||
@pytest.mark.parametrize(
|
||||
"err",
|
||||
[
|
||||
aiodocker.DockerError(HTTPStatus.TOO_MANY_REQUESTS, {"message": "ratelimit"}),
|
||||
APIError("ratelimit", MagicMock(status_code=HTTPStatus.TOO_MANY_REQUESTS)),
|
||||
],
|
||||
)
|
||||
async def test_install_landingpage_docker_ratelimit_error(
|
||||
coresys: CoreSys,
|
||||
capture_exception: Mock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
err: Exception,
|
||||
):
|
||||
"""Test install landing page fails due to docker error."""
|
||||
"""Test install landing page fails due to docker ratelimit error."""
|
||||
coresys.security.force = True
|
||||
coresys.docker.images.pull.side_effect = [err, AsyncIterator([{}])]
|
||||
|
||||
with (
|
||||
patch.object(DockerHomeAssistant, "attach", side_effect=DockerError),
|
||||
patch.object(
|
||||
@@ -69,19 +88,35 @@ async def test_install_landingpage_docker_error(
|
||||
),
|
||||
patch("supervisor.homeassistant.core.asyncio.sleep") as sleep,
|
||||
):
|
||||
coresys.docker.images.get.side_effect = [APIError("fail"), MagicMock()]
|
||||
await coresys.homeassistant.core.install_landingpage()
|
||||
sleep.assert_awaited_once_with(30)
|
||||
|
||||
assert "Failed to install landingpage, retrying after 30sec" in caplog.text
|
||||
capture_exception.assert_not_called()
|
||||
assert (
|
||||
Issue(IssueType.DOCKER_RATELIMIT, ContextType.SYSTEM)
|
||||
in coresys.resolution.issues
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"err",
|
||||
[
|
||||
aiodocker.DockerError(HTTPStatus.INTERNAL_SERVER_ERROR, {"message": "fail"}),
|
||||
APIError("fail"),
|
||||
DockerException(),
|
||||
RequestException(),
|
||||
OSError(),
|
||||
],
|
||||
)
|
||||
async def test_install_landingpage_other_error(
|
||||
coresys: CoreSys, capture_exception: Mock, caplog: pytest.LogCaptureFixture
|
||||
coresys: CoreSys,
|
||||
capture_exception: Mock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
err: Exception,
|
||||
):
|
||||
"""Test install landing page fails due to other error."""
|
||||
coresys.docker.images.get.side_effect = [(err := OSError()), MagicMock()]
|
||||
coresys.docker.images.inspect.side_effect = [err, MagicMock()]
|
||||
|
||||
with (
|
||||
patch.object(DockerHomeAssistant, "attach", side_effect=DockerError),
|
||||
@@ -102,11 +137,23 @@ async def test_install_landingpage_other_error(
|
||||
capture_exception.assert_called_once_with(err)
|
||||
|
||||
|
||||
async def test_install_docker_error(
|
||||
coresys: CoreSys, capture_exception: Mock, caplog: pytest.LogCaptureFixture
|
||||
@pytest.mark.parametrize(
|
||||
"err",
|
||||
[
|
||||
aiodocker.DockerError(HTTPStatus.TOO_MANY_REQUESTS, {"message": "ratelimit"}),
|
||||
APIError("ratelimit", MagicMock(status_code=HTTPStatus.TOO_MANY_REQUESTS)),
|
||||
],
|
||||
)
|
||||
async def test_install_docker_ratelimit_error(
|
||||
coresys: CoreSys,
|
||||
capture_exception: Mock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
err: Exception,
|
||||
):
|
||||
"""Test install fails due to docker error."""
|
||||
"""Test install fails due to docker ratelimit error."""
|
||||
coresys.security.force = True
|
||||
coresys.docker.images.pull.side_effect = [err, AsyncIterator([{}])]
|
||||
|
||||
with (
|
||||
patch.object(HomeAssistantCore, "start"),
|
||||
patch.object(DockerHomeAssistant, "cleanup"),
|
||||
@@ -123,19 +170,35 @@ async def test_install_docker_error(
|
||||
),
|
||||
patch("supervisor.homeassistant.core.asyncio.sleep") as sleep,
|
||||
):
|
||||
coresys.docker.images.get.side_effect = [APIError("fail"), MagicMock()]
|
||||
await coresys.homeassistant.core.install()
|
||||
sleep.assert_awaited_once_with(30)
|
||||
|
||||
assert "Error on Home Assistant installation. Retrying in 30sec" in caplog.text
|
||||
capture_exception.assert_not_called()
|
||||
assert (
|
||||
Issue(IssueType.DOCKER_RATELIMIT, ContextType.SYSTEM)
|
||||
in coresys.resolution.issues
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"err",
|
||||
[
|
||||
aiodocker.DockerError(HTTPStatus.INTERNAL_SERVER_ERROR, {"message": "fail"}),
|
||||
APIError("fail"),
|
||||
DockerException(),
|
||||
RequestException(),
|
||||
OSError(),
|
||||
],
|
||||
)
|
||||
async def test_install_other_error(
|
||||
coresys: CoreSys, capture_exception: Mock, caplog: pytest.LogCaptureFixture
|
||||
coresys: CoreSys,
|
||||
capture_exception: Mock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
err: Exception,
|
||||
):
|
||||
"""Test install fails due to other error."""
|
||||
coresys.docker.images.get.side_effect = [(err := OSError()), MagicMock()]
|
||||
coresys.docker.images.inspect.side_effect = [err, MagicMock()]
|
||||
|
||||
with (
|
||||
patch.object(HomeAssistantCore, "start"),
|
||||
@@ -161,21 +224,29 @@ async def test_install_other_error(
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"container_exists,image_exists", [(False, True), (True, False), (True, True)]
|
||||
("container_exc", "image_exc", "remove_calls"),
|
||||
[
|
||||
(NotFound("missing"), None, []),
|
||||
(
|
||||
None,
|
||||
aiodocker.DockerError(404, {"message": "missing"}),
|
||||
[call(force=True, v=True)],
|
||||
),
|
||||
(None, None, [call(force=True, v=True)]),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("path_extern")
|
||||
async def test_start(
|
||||
coresys: CoreSys, container_exists: bool, image_exists: bool, path_extern
|
||||
coresys: CoreSys,
|
||||
container_exc: DockerException | None,
|
||||
image_exc: aiodocker.DockerError | None,
|
||||
remove_calls: list[call],
|
||||
):
|
||||
"""Test starting Home Assistant."""
|
||||
if image_exists:
|
||||
coresys.docker.images.get.return_value.id = "123"
|
||||
else:
|
||||
coresys.docker.images.get.side_effect = ImageNotFound("missing")
|
||||
|
||||
if container_exists:
|
||||
coresys.docker.containers.get.return_value.image.id = "123"
|
||||
else:
|
||||
coresys.docker.containers.get.side_effect = NotFound("missing")
|
||||
coresys.docker.images.inspect.return_value = {"Id": "123"}
|
||||
coresys.docker.images.inspect.side_effect = image_exc
|
||||
coresys.docker.containers.get.return_value.id = "123"
|
||||
coresys.docker.containers.get.side_effect = container_exc
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
@@ -198,18 +269,14 @@ async def test_start(
|
||||
assert run.call_args.kwargs["hostname"] == "homeassistant"
|
||||
|
||||
coresys.docker.containers.get.return_value.stop.assert_not_called()
|
||||
if container_exists:
|
||||
coresys.docker.containers.get.return_value.remove.assert_called_once_with(
|
||||
force=True,
|
||||
v=True,
|
||||
)
|
||||
else:
|
||||
coresys.docker.containers.get.return_value.remove.assert_not_called()
|
||||
assert (
|
||||
coresys.docker.containers.get.return_value.remove.call_args_list == remove_calls
|
||||
)
|
||||
|
||||
|
||||
async def test_start_existing_container(coresys: CoreSys, path_extern):
|
||||
"""Test starting Home Assistant when container exists and is viable."""
|
||||
coresys.docker.images.get.return_value.id = "123"
|
||||
coresys.docker.images.inspect.return_value = {"Id": "123"}
|
||||
coresys.docker.containers.get.return_value.image.id = "123"
|
||||
coresys.docker.containers.get.return_value.status = "exited"
|
||||
|
||||
@@ -394,24 +461,32 @@ async def test_core_loads_wrong_image_for_machine(
|
||||
"""Test core is loaded with wrong image for machine."""
|
||||
coresys.homeassistant.set_image("ghcr.io/home-assistant/odroid-n2-homeassistant")
|
||||
coresys.homeassistant.version = AwesomeVersion("2024.4.0")
|
||||
container.attrs["Config"] = {"Labels": {"io.hass.version": "2024.4.0"}}
|
||||
|
||||
await coresys.homeassistant.core.load()
|
||||
with patch.object(
|
||||
DockerAPI,
|
||||
"pull_image",
|
||||
return_value={
|
||||
"Id": "abc123",
|
||||
"Config": {"Labels": {"io.hass.version": "2024.4.0"}},
|
||||
},
|
||||
) as pull_image:
|
||||
container.attrs |= pull_image.return_value
|
||||
await coresys.homeassistant.core.load()
|
||||
pull_image.assert_called_once_with(
|
||||
ANY,
|
||||
"ghcr.io/home-assistant/qemux86-64-homeassistant",
|
||||
"2024.4.0",
|
||||
platform="linux/amd64",
|
||||
)
|
||||
|
||||
container.remove.assert_called_once_with(force=True, v=True)
|
||||
assert coresys.docker.images.remove.call_args_list[0].kwargs == {
|
||||
"image": "ghcr.io/home-assistant/odroid-n2-homeassistant:latest",
|
||||
"force": True,
|
||||
}
|
||||
assert coresys.docker.images.remove.call_args_list[1].kwargs == {
|
||||
"image": "ghcr.io/home-assistant/odroid-n2-homeassistant:2024.4.0",
|
||||
"force": True,
|
||||
}
|
||||
coresys.docker.pull_image.assert_called_once_with(
|
||||
ANY,
|
||||
"ghcr.io/home-assistant/qemux86-64-homeassistant",
|
||||
"2024.4.0",
|
||||
platform="linux/amd64",
|
||||
assert coresys.docker.images.delete.call_args_list[0] == call(
|
||||
"ghcr.io/home-assistant/odroid-n2-homeassistant:latest",
|
||||
force=True,
|
||||
)
|
||||
assert coresys.docker.images.delete.call_args_list[1] == call(
|
||||
"ghcr.io/home-assistant/odroid-n2-homeassistant:2024.4.0",
|
||||
force=True,
|
||||
)
|
||||
assert (
|
||||
coresys.homeassistant.image == "ghcr.io/home-assistant/qemux86-64-homeassistant"
|
||||
@@ -428,8 +503,8 @@ async def test_core_load_allows_image_override(coresys: CoreSys, container: Magi
|
||||
await coresys.homeassistant.core.load()
|
||||
|
||||
container.remove.assert_not_called()
|
||||
coresys.docker.images.remove.assert_not_called()
|
||||
coresys.docker.images.get.assert_not_called()
|
||||
coresys.docker.images.delete.assert_not_called()
|
||||
coresys.docker.images.inspect.assert_not_called()
|
||||
assert (
|
||||
coresys.homeassistant.image == "ghcr.io/home-assistant/odroid-n2-homeassistant"
|
||||
)
|
||||
@@ -440,27 +515,36 @@ async def test_core_loads_wrong_image_for_architecture(
|
||||
):
|
||||
"""Test core is loaded with wrong image for architecture."""
|
||||
coresys.homeassistant.version = AwesomeVersion("2024.4.0")
|
||||
container.attrs["Config"] = {"Labels": {"io.hass.version": "2024.4.0"}}
|
||||
coresys.docker.images.get("ghcr.io/home-assistant/qemux86-64-homeassistant").attrs[
|
||||
"Architecture"
|
||||
] = "arm64"
|
||||
coresys.docker.images.inspect.return_value = img_data = (
|
||||
coresys.docker.images.inspect.return_value
|
||||
| {
|
||||
"Architecture": "arm64",
|
||||
"Config": {"Labels": {"io.hass.version": "2024.4.0"}},
|
||||
}
|
||||
)
|
||||
container.attrs |= img_data
|
||||
|
||||
await coresys.homeassistant.core.load()
|
||||
with patch.object(
|
||||
DockerAPI,
|
||||
"pull_image",
|
||||
return_value=img_data | {"Architecture": "amd64"},
|
||||
) as pull_image:
|
||||
await coresys.homeassistant.core.load()
|
||||
pull_image.assert_called_once_with(
|
||||
ANY,
|
||||
"ghcr.io/home-assistant/qemux86-64-homeassistant",
|
||||
"2024.4.0",
|
||||
platform="linux/amd64",
|
||||
)
|
||||
|
||||
container.remove.assert_called_once_with(force=True, v=True)
|
||||
assert coresys.docker.images.remove.call_args_list[0].kwargs == {
|
||||
"image": "ghcr.io/home-assistant/qemux86-64-homeassistant:latest",
|
||||
"force": True,
|
||||
}
|
||||
assert coresys.docker.images.remove.call_args_list[1].kwargs == {
|
||||
"image": "ghcr.io/home-assistant/qemux86-64-homeassistant:2024.4.0",
|
||||
"force": True,
|
||||
}
|
||||
coresys.docker.pull_image.assert_called_once_with(
|
||||
ANY,
|
||||
"ghcr.io/home-assistant/qemux86-64-homeassistant",
|
||||
"2024.4.0",
|
||||
platform="linux/amd64",
|
||||
assert coresys.docker.images.delete.call_args_list[0] == call(
|
||||
"ghcr.io/home-assistant/qemux86-64-homeassistant:latest",
|
||||
force=True,
|
||||
)
|
||||
assert coresys.docker.images.delete.call_args_list[1] == call(
|
||||
"ghcr.io/home-assistant/qemux86-64-homeassistant:2024.4.0",
|
||||
force=True,
|
||||
)
|
||||
assert (
|
||||
coresys.homeassistant.image == "ghcr.io/home-assistant/qemux86-64-homeassistant"
|
||||
|
||||
@@ -200,6 +200,8 @@ async def test_notify_on_change(coresys: CoreSys, ha_ws_client: AsyncMock):
|
||||
"type": "HassioError",
|
||||
"message": "Unknown error, see Supervisor logs (check with 'ha supervisor logs')",
|
||||
"stage": "test",
|
||||
"error_key": None,
|
||||
"extra_fields": None,
|
||||
}
|
||||
],
|
||||
"created": ANY,
|
||||
@@ -228,6 +230,8 @@ async def test_notify_on_change(coresys: CoreSys, ha_ws_client: AsyncMock):
|
||||
"type": "HassioError",
|
||||
"message": "Unknown error, see Supervisor logs (check with 'ha supervisor logs')",
|
||||
"stage": "test",
|
||||
"error_key": None,
|
||||
"extra_fields": None,
|
||||
}
|
||||
],
|
||||
"created": ANY,
|
||||
|
||||
@@ -115,7 +115,17 @@ async def test_not_started(coresys):
|
||||
assert filter_data(coresys, SAMPLE_EVENT, {}) == SAMPLE_EVENT
|
||||
|
||||
await coresys.core.set_state(CoreState.SETUP)
|
||||
assert filter_data(coresys, SAMPLE_EVENT, {}) == SAMPLE_EVENT
|
||||
filtered = filter_data(coresys, SAMPLE_EVENT, {})
|
||||
# During SETUP, we should have basic system info available
|
||||
assert "contexts" in filtered
|
||||
assert "versions" in filtered["contexts"]
|
||||
assert "docker" in filtered["contexts"]["versions"]
|
||||
assert "supervisor" in filtered["contexts"]["versions"]
|
||||
assert "host" in filtered["contexts"]
|
||||
assert "machine" in filtered["contexts"]["host"]
|
||||
assert filtered["contexts"]["versions"]["docker"] == coresys.docker.info.version
|
||||
assert filtered["contexts"]["versions"]["supervisor"] == coresys.supervisor.version
|
||||
assert filtered["contexts"]["host"]["machine"] == coresys.machine
|
||||
|
||||
|
||||
async def test_defaults(coresys):
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import ANY, MagicMock, Mock, PropertyMock, patch
|
||||
from unittest.mock import ANY, MagicMock, Mock, PropertyMock, call, patch
|
||||
|
||||
from awesomeversion import AwesomeVersion
|
||||
import pytest
|
||||
@@ -11,6 +11,7 @@ from supervisor.const import BusEvent, CpuArch
|
||||
from supervisor.coresys import CoreSys
|
||||
from supervisor.docker.const import ContainerState
|
||||
from supervisor.docker.interface import DockerInterface
|
||||
from supervisor.docker.manager import DockerAPI
|
||||
from supervisor.docker.monitor import DockerContainerStateEvent
|
||||
from supervisor.exceptions import (
|
||||
AudioError,
|
||||
@@ -359,21 +360,26 @@ async def test_load_with_incorrect_image(
|
||||
plugin.version = AwesomeVersion("2024.4.0")
|
||||
|
||||
container.status = "running"
|
||||
container.attrs["Config"] = {"Labels": {"io.hass.version": "2024.4.0"}}
|
||||
coresys.docker.images.inspect.return_value = img_data = (
|
||||
coresys.docker.images.inspect.return_value
|
||||
| {"Config": {"Labels": {"io.hass.version": "2024.4.0"}}}
|
||||
)
|
||||
container.attrs |= img_data
|
||||
|
||||
await plugin.load()
|
||||
with patch.object(DockerAPI, "pull_image", return_value=img_data) as pull_image:
|
||||
await plugin.load()
|
||||
pull_image.assert_called_once_with(
|
||||
ANY, correct_image, "2024.4.0", platform="linux/amd64"
|
||||
)
|
||||
|
||||
container.remove.assert_called_once_with(force=True, v=True)
|
||||
assert coresys.docker.images.remove.call_args_list[0].kwargs == {
|
||||
"image": f"{old_image}:latest",
|
||||
"force": True,
|
||||
}
|
||||
assert coresys.docker.images.remove.call_args_list[1].kwargs == {
|
||||
"image": f"{old_image}:2024.4.0",
|
||||
"force": True,
|
||||
}
|
||||
coresys.docker.pull_image.assert_called_once_with(
|
||||
ANY, correct_image, "2024.4.0", platform="linux/amd64"
|
||||
assert coresys.docker.images.delete.call_args_list[0] == call(
|
||||
f"{old_image}:latest",
|
||||
force=True,
|
||||
)
|
||||
assert coresys.docker.images.delete.call_args_list[1] == call(
|
||||
f"{old_image}:2024.4.0",
|
||||
force=True,
|
||||
)
|
||||
assert plugin.image == correct_image
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Test fixup addon execute repair."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from http import HTTPStatus
|
||||
from unittest.mock import patch
|
||||
|
||||
from docker.errors import NotFound
|
||||
import aiodocker
|
||||
import pytest
|
||||
|
||||
from supervisor.addons.addon import Addon
|
||||
@@ -17,7 +18,9 @@ from supervisor.resolution.fixups.addon_execute_repair import FixupAddonExecuteR
|
||||
|
||||
async def test_fixup(docker: DockerAPI, coresys: CoreSys, install_addon_ssh: Addon):
|
||||
"""Test fixup rebuilds addon's container."""
|
||||
docker.images.get.side_effect = NotFound("missing")
|
||||
docker.images.inspect.side_effect = aiodocker.DockerError(
|
||||
HTTPStatus.NOT_FOUND, {"message": "missing"}
|
||||
)
|
||||
install_addon_ssh.data["image"] = "test_image"
|
||||
|
||||
addon_execute_repair = FixupAddonExecuteRepair(coresys)
|
||||
@@ -41,7 +44,9 @@ async def test_fixup_max_auto_attempts(
|
||||
docker: DockerAPI, coresys: CoreSys, install_addon_ssh: Addon
|
||||
):
|
||||
"""Test fixup stops being auto-applied after 5 failures."""
|
||||
docker.images.get.side_effect = NotFound("missing")
|
||||
docker.images.inspect.side_effect = aiodocker.DockerError(
|
||||
HTTPStatus.NOT_FOUND, {"message": "missing"}
|
||||
)
|
||||
install_addon_ssh.data["image"] = "test_image"
|
||||
|
||||
addon_execute_repair = FixupAddonExecuteRepair(coresys)
|
||||
@@ -82,8 +87,6 @@ async def test_fixup_image_exists(
|
||||
docker: DockerAPI, coresys: CoreSys, install_addon_ssh: Addon
|
||||
):
|
||||
"""Test fixup dismisses if image exists."""
|
||||
docker.images.get.return_value = MagicMock()
|
||||
|
||||
addon_execute_repair = FixupAddonExecuteRepair(coresys)
|
||||
assert addon_execute_repair.auto is True
|
||||
|
||||
|
||||
Reference in New Issue
Block a user