mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-12-12 02:48:26 +00:00
Compare commits
11 Commits
autoupdate
...
container-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc44e117a9 | ||
|
|
4df0db9df4 | ||
|
|
ed2275a8cf | ||
|
|
c29a82c47d | ||
|
|
0599238217 | ||
|
|
b30be21df4 | ||
|
|
7d2bfe8fa6 | ||
|
|
27c53048f6 | ||
|
|
88ab5e9196 | ||
|
|
b7a7475d47 | ||
|
|
5fe6b934e2 |
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -428,4 +428,4 @@ jobs:
|
||||
coverage report
|
||||
coverage xml
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
|
||||
2
.github/workflows/update_frontend.yml
vendored
2
.github/workflows/update_frontend.yml
vendored
@@ -68,7 +68,7 @@ jobs:
|
||||
run: |
|
||||
rm -f supervisor/api/panel/home_assistant_frontend_supervisor-*.tar.gz
|
||||
- name: Create PR
|
||||
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
with:
|
||||
commit-message: "Update frontend to version ${{ needs.check-version.outputs.latest_version }}"
|
||||
branch: autoupdate-frontend
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
aiodns==3.6.0
|
||||
aiodns==3.6.1
|
||||
aiodocker==0.24.0
|
||||
aiohttp==3.13.2
|
||||
atomicwrites-homeassistant==1.4.1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
astroid==4.0.2
|
||||
coverage==7.12.0
|
||||
coverage==7.13.0
|
||||
mypy==1.19.0
|
||||
pre-commit==4.5.0
|
||||
pylint==4.0.4
|
||||
@@ -13,4 +13,4 @@ time-machine==3.1.0
|
||||
types-docker==7.1.0.20251202
|
||||
types-pyyaml==6.0.12.20250915
|
||||
types-requests==2.32.4.20250913
|
||||
urllib3==2.6.0
|
||||
urllib3==2.6.1
|
||||
|
||||
@@ -10,14 +10,13 @@ import os
|
||||
from pathlib import Path
|
||||
from socket import SocketIO
|
||||
import tempfile
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from typing import TYPE_CHECKING, Literal, cast
|
||||
|
||||
import aiodocker
|
||||
from attr import evolve
|
||||
from awesomeversion import AwesomeVersion
|
||||
import docker
|
||||
import docker.errors
|
||||
from docker.types import Mount
|
||||
import requests
|
||||
|
||||
from ..addons.build import AddonBuild
|
||||
@@ -68,8 +67,11 @@ from .const import (
|
||||
PATH_SHARE,
|
||||
PATH_SSL,
|
||||
Capabilities,
|
||||
DockerMount,
|
||||
MountBindOptions,
|
||||
MountType,
|
||||
PropagationMode,
|
||||
Ulimit,
|
||||
)
|
||||
from .interface import DockerInterface
|
||||
|
||||
@@ -272,7 +274,7 @@ class DockerAddon(DockerInterface):
|
||||
}
|
||||
|
||||
@property
|
||||
def network_mode(self) -> str | None:
|
||||
def network_mode(self) -> Literal["host"] | None:
|
||||
"""Return network mode for add-on."""
|
||||
if self.addon.host_network:
|
||||
return "host"
|
||||
@@ -311,28 +313,28 @@ class DockerAddon(DockerInterface):
|
||||
return None
|
||||
|
||||
@property
|
||||
def ulimits(self) -> list[docker.types.Ulimit] | None:
|
||||
def ulimits(self) -> list[Ulimit] | None:
|
||||
"""Generate ulimits for add-on."""
|
||||
limits: list[docker.types.Ulimit] = []
|
||||
limits: list[Ulimit] = []
|
||||
|
||||
# Need schedule functions
|
||||
if self.addon.with_realtime:
|
||||
limits.append(docker.types.Ulimit(name="rtprio", soft=90, hard=99))
|
||||
limits.append(Ulimit(name="rtprio", soft=90, hard=99))
|
||||
|
||||
# Set available memory for memlock to 128MB
|
||||
mem = 128 * 1024 * 1024
|
||||
limits.append(docker.types.Ulimit(name="memlock", soft=mem, hard=mem))
|
||||
limits.append(Ulimit(name="memlock", soft=mem, hard=mem))
|
||||
|
||||
# Add configurable ulimits from add-on config
|
||||
for name, config in self.addon.ulimits.items():
|
||||
if isinstance(config, int):
|
||||
# Simple format: both soft and hard limits are the same
|
||||
limits.append(docker.types.Ulimit(name=name, soft=config, hard=config))
|
||||
limits.append(Ulimit(name=name, soft=config, hard=config))
|
||||
elif isinstance(config, dict):
|
||||
# Detailed format: both soft and hard limits are mandatory
|
||||
soft = config["soft"]
|
||||
hard = config["hard"]
|
||||
limits.append(docker.types.Ulimit(name=name, soft=soft, hard=hard))
|
||||
limits.append(Ulimit(name=name, soft=soft, hard=hard))
|
||||
|
||||
# Return None if no ulimits are present
|
||||
if limits:
|
||||
@@ -351,7 +353,7 @@ class DockerAddon(DockerInterface):
|
||||
return None
|
||||
|
||||
@property
|
||||
def mounts(self) -> list[Mount]:
|
||||
def mounts(self) -> list[DockerMount]:
|
||||
"""Return mounts for container."""
|
||||
addon_mapping = self.addon.map_volumes
|
||||
|
||||
@@ -361,8 +363,8 @@ class DockerAddon(DockerInterface):
|
||||
|
||||
mounts = [
|
||||
MOUNT_DEV,
|
||||
Mount(
|
||||
type=MountType.BIND.value,
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=self.addon.path_extern_data.as_posix(),
|
||||
target=target_data_path or PATH_PRIVATE_DATA.as_posix(),
|
||||
read_only=False,
|
||||
@@ -372,8 +374,8 @@ class DockerAddon(DockerInterface):
|
||||
# setup config mappings
|
||||
if MappingType.CONFIG in addon_mapping:
|
||||
mounts.append(
|
||||
Mount(
|
||||
type=MountType.BIND.value,
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=self.sys_config.path_extern_homeassistant.as_posix(),
|
||||
target=addon_mapping[MappingType.CONFIG].path
|
||||
or PATH_HOMEASSISTANT_CONFIG_LEGACY.as_posix(),
|
||||
@@ -385,8 +387,8 @@ class DockerAddon(DockerInterface):
|
||||
# Map addon's public config folder if not using deprecated config option
|
||||
if self.addon.addon_config_used:
|
||||
mounts.append(
|
||||
Mount(
|
||||
type=MountType.BIND.value,
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=self.addon.path_extern_config.as_posix(),
|
||||
target=addon_mapping[MappingType.ADDON_CONFIG].path
|
||||
or PATH_PUBLIC_CONFIG.as_posix(),
|
||||
@@ -397,8 +399,8 @@ class DockerAddon(DockerInterface):
|
||||
# Map Home Assistant config in new way
|
||||
if MappingType.HOMEASSISTANT_CONFIG in addon_mapping:
|
||||
mounts.append(
|
||||
Mount(
|
||||
type=MountType.BIND.value,
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=self.sys_config.path_extern_homeassistant.as_posix(),
|
||||
target=addon_mapping[MappingType.HOMEASSISTANT_CONFIG].path
|
||||
or PATH_HOMEASSISTANT_CONFIG.as_posix(),
|
||||
@@ -410,8 +412,8 @@ class DockerAddon(DockerInterface):
|
||||
|
||||
if MappingType.ALL_ADDON_CONFIGS in addon_mapping:
|
||||
mounts.append(
|
||||
Mount(
|
||||
type=MountType.BIND.value,
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=self.sys_config.path_extern_addon_configs.as_posix(),
|
||||
target=addon_mapping[MappingType.ALL_ADDON_CONFIGS].path
|
||||
or PATH_ALL_ADDON_CONFIGS.as_posix(),
|
||||
@@ -421,8 +423,8 @@ class DockerAddon(DockerInterface):
|
||||
|
||||
if MappingType.SSL in addon_mapping:
|
||||
mounts.append(
|
||||
Mount(
|
||||
type=MountType.BIND.value,
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=self.sys_config.path_extern_ssl.as_posix(),
|
||||
target=addon_mapping[MappingType.SSL].path or PATH_SSL.as_posix(),
|
||||
read_only=addon_mapping[MappingType.SSL].read_only,
|
||||
@@ -431,8 +433,8 @@ class DockerAddon(DockerInterface):
|
||||
|
||||
if MappingType.ADDONS in addon_mapping:
|
||||
mounts.append(
|
||||
Mount(
|
||||
type=MountType.BIND.value,
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=self.sys_config.path_extern_addons_local.as_posix(),
|
||||
target=addon_mapping[MappingType.ADDONS].path
|
||||
or PATH_LOCAL_ADDONS.as_posix(),
|
||||
@@ -442,8 +444,8 @@ class DockerAddon(DockerInterface):
|
||||
|
||||
if MappingType.BACKUP in addon_mapping:
|
||||
mounts.append(
|
||||
Mount(
|
||||
type=MountType.BIND.value,
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=self.sys_config.path_extern_backup.as_posix(),
|
||||
target=addon_mapping[MappingType.BACKUP].path
|
||||
or PATH_BACKUP.as_posix(),
|
||||
@@ -453,25 +455,25 @@ class DockerAddon(DockerInterface):
|
||||
|
||||
if MappingType.SHARE in addon_mapping:
|
||||
mounts.append(
|
||||
Mount(
|
||||
type=MountType.BIND.value,
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=self.sys_config.path_extern_share.as_posix(),
|
||||
target=addon_mapping[MappingType.SHARE].path
|
||||
or PATH_SHARE.as_posix(),
|
||||
read_only=addon_mapping[MappingType.SHARE].read_only,
|
||||
propagation=PropagationMode.RSLAVE,
|
||||
bind_options=MountBindOptions(propagation=PropagationMode.RSLAVE),
|
||||
)
|
||||
)
|
||||
|
||||
if MappingType.MEDIA in addon_mapping:
|
||||
mounts.append(
|
||||
Mount(
|
||||
type=MountType.BIND.value,
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=self.sys_config.path_extern_media.as_posix(),
|
||||
target=addon_mapping[MappingType.MEDIA].path
|
||||
or PATH_MEDIA.as_posix(),
|
||||
read_only=addon_mapping[MappingType.MEDIA].read_only,
|
||||
propagation=PropagationMode.RSLAVE,
|
||||
bind_options=MountBindOptions(propagation=PropagationMode.RSLAVE),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -483,8 +485,8 @@ class DockerAddon(DockerInterface):
|
||||
if not Path(gpio_path).exists():
|
||||
continue
|
||||
mounts.append(
|
||||
Mount(
|
||||
type=MountType.BIND.value,
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=gpio_path,
|
||||
target=gpio_path,
|
||||
read_only=False,
|
||||
@@ -494,8 +496,8 @@ class DockerAddon(DockerInterface):
|
||||
# DeviceTree support
|
||||
if self.addon.with_devicetree:
|
||||
mounts.append(
|
||||
Mount(
|
||||
type=MountType.BIND.value,
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source="/sys/firmware/devicetree/base",
|
||||
target="/device-tree",
|
||||
read_only=True,
|
||||
@@ -509,8 +511,8 @@ class DockerAddon(DockerInterface):
|
||||
# Kernel Modules support
|
||||
if self.addon.with_kernel_modules:
|
||||
mounts.append(
|
||||
Mount(
|
||||
type=MountType.BIND.value,
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source="/lib/modules",
|
||||
target="/lib/modules",
|
||||
read_only=True,
|
||||
@@ -528,20 +530,20 @@ class DockerAddon(DockerInterface):
|
||||
# Configuration Audio
|
||||
if self.addon.with_audio:
|
||||
mounts += [
|
||||
Mount(
|
||||
type=MountType.BIND.value,
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=self.addon.path_extern_pulse.as_posix(),
|
||||
target="/etc/pulse/client.conf",
|
||||
read_only=True,
|
||||
),
|
||||
Mount(
|
||||
type=MountType.BIND.value,
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=self.sys_plugins.audio.path_extern_pulse.as_posix(),
|
||||
target="/run/audio",
|
||||
read_only=True,
|
||||
),
|
||||
Mount(
|
||||
type=MountType.BIND.value,
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=self.sys_plugins.audio.path_extern_asound.as_posix(),
|
||||
target="/etc/asound.conf",
|
||||
read_only=True,
|
||||
@@ -551,14 +553,14 @@ class DockerAddon(DockerInterface):
|
||||
# System Journal access
|
||||
if self.addon.with_journald:
|
||||
mounts += [
|
||||
Mount(
|
||||
type=MountType.BIND.value,
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=SYSTEMD_JOURNAL_PERSISTENT.as_posix(),
|
||||
target=SYSTEMD_JOURNAL_PERSISTENT.as_posix(),
|
||||
read_only=True,
|
||||
),
|
||||
Mount(
|
||||
type=MountType.BIND.value,
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=SYSTEMD_JOURNAL_VOLATILE.as_posix(),
|
||||
target=SYSTEMD_JOURNAL_VOLATILE.as_posix(),
|
||||
read_only=True,
|
||||
@@ -706,7 +708,9 @@ class DockerAddon(DockerInterface):
|
||||
# Remove dangling builder container if it exists by any chance
|
||||
# E.g. because of an abrupt host shutdown/reboot during a build
|
||||
with suppress(docker.errors.NotFound):
|
||||
self.sys_docker.containers.get(builder_name).remove(force=True, v=True)
|
||||
self.sys_docker.containers_legacy.get(builder_name).remove(
|
||||
force=True, v=True
|
||||
)
|
||||
|
||||
# Generate Docker config with registry credentials for base image if needed
|
||||
docker_config_path: Path | None = None
|
||||
@@ -833,7 +837,7 @@ class DockerAddon(DockerInterface):
|
||||
"""
|
||||
try:
|
||||
# Load needed docker objects
|
||||
container = self.sys_docker.containers.get(self.name)
|
||||
container = self.sys_docker.containers_legacy.get(self.name)
|
||||
# attach_socket returns SocketIO for local Docker connections (Unix socket)
|
||||
socket = cast(
|
||||
SocketIO, container.attach_socket(params={"stdin": 1, "stream": 1})
|
||||
@@ -896,7 +900,7 @@ class DockerAddon(DockerInterface):
|
||||
|
||||
try:
|
||||
docker_container = await self.sys_run_in_executor(
|
||||
self.sys_docker.containers.get, self.name
|
||||
self.sys_docker.containers_legacy.get, self.name
|
||||
)
|
||||
except docker.errors.NotFound:
|
||||
if self._hw_listener:
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
|
||||
import logging
|
||||
|
||||
import docker
|
||||
from docker.types import Mount
|
||||
|
||||
from ..const import DOCKER_CPU_RUNTIME_ALLOCATION
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..exceptions import DockerJobError
|
||||
@@ -19,7 +16,9 @@ from .const import (
|
||||
MOUNT_UDEV,
|
||||
PATH_PRIVATE_DATA,
|
||||
Capabilities,
|
||||
DockerMount,
|
||||
MountType,
|
||||
Ulimit,
|
||||
)
|
||||
from .interface import DockerInterface
|
||||
|
||||
@@ -42,12 +41,12 @@ class DockerAudio(DockerInterface, CoreSysAttributes):
|
||||
return AUDIO_DOCKER_NAME
|
||||
|
||||
@property
|
||||
def mounts(self) -> list[Mount]:
|
||||
def mounts(self) -> list[DockerMount]:
|
||||
"""Return mounts for container."""
|
||||
mounts = [
|
||||
MOUNT_DEV,
|
||||
Mount(
|
||||
type=MountType.BIND.value,
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=self.sys_config.path_extern_audio.as_posix(),
|
||||
target=PATH_PRIVATE_DATA.as_posix(),
|
||||
read_only=False,
|
||||
@@ -75,10 +74,10 @@ class DockerAudio(DockerInterface, CoreSysAttributes):
|
||||
return [Capabilities.SYS_NICE, Capabilities.SYS_RESOURCE]
|
||||
|
||||
@property
|
||||
def ulimits(self) -> list[docker.types.Ulimit]:
|
||||
def ulimits(self) -> list[Ulimit]:
|
||||
"""Generate ulimits for audio."""
|
||||
# Pulseaudio by default tries to use real-time scheduling with priority of 5.
|
||||
return [docker.types.Ulimit(name="rtprio", soft=10, hard=10)]
|
||||
return [Ulimit(name="rtprio", soft=10, hard=10)]
|
||||
|
||||
@property
|
||||
def cpu_rt_runtime(self) -> int | None:
|
||||
|
||||
@@ -3,13 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, StrEnum
|
||||
from functools import total_ordering
|
||||
from pathlib import PurePath
|
||||
import re
|
||||
from typing import cast
|
||||
|
||||
from docker.types import Mount
|
||||
from typing import Any, cast
|
||||
|
||||
from ..const import MACHINE_ID
|
||||
|
||||
@@ -133,6 +132,63 @@ class PullImageLayerStage(Enum):
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class MountBindOptions:
|
||||
"""Bind options for docker mount."""
|
||||
|
||||
propagation: PropagationMode | None = None
|
||||
read_only_non_recursive: bool | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""To dictionary representation."""
|
||||
out: dict[str, Any] = {}
|
||||
if self.propagation:
|
||||
out["Propagation"] = self.propagation.value
|
||||
if self.read_only_non_recursive is not None:
|
||||
out["ReadOnlyNonRecursive"] = self.read_only_non_recursive
|
||||
return out
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class DockerMount:
|
||||
"""A docker mount."""
|
||||
|
||||
type: MountType
|
||||
source: str
|
||||
target: str
|
||||
read_only: bool
|
||||
bind_options: MountBindOptions | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""To dictionary representation."""
|
||||
out: dict[str, Any] = {
|
||||
"Type": self.type.value,
|
||||
"Source": self.source,
|
||||
"Target": self.target,
|
||||
"ReadOnly": self.read_only,
|
||||
}
|
||||
if self.bind_options:
|
||||
out["BindOptions"] = self.bind_options.to_dict()
|
||||
return out
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class Ulimit:
|
||||
"""A linux user limit."""
|
||||
|
||||
name: str
|
||||
soft: int
|
||||
hard: int
|
||||
|
||||
def to_dict(self) -> dict[str, str | int]:
|
||||
"""To dictionary representation."""
|
||||
return {
|
||||
"Name": self.name,
|
||||
"Soft": self.soft,
|
||||
"Hard": self.hard,
|
||||
}
|
||||
|
||||
|
||||
ENV_DUPLICATE_LOG_FILE = "HA_DUPLICATE_LOG_FILE"
|
||||
ENV_TIME = "TZ"
|
||||
ENV_TOKEN = "SUPERVISOR_TOKEN"
|
||||
@@ -140,27 +196,30 @@ ENV_TOKEN_OLD = "HASSIO_TOKEN"
|
||||
|
||||
LABEL_MANAGED = "supervisor_managed"
|
||||
|
||||
MOUNT_DBUS = Mount(
|
||||
type=MountType.BIND.value, source="/run/dbus", target="/run/dbus", read_only=True
|
||||
MOUNT_DBUS = DockerMount(
|
||||
type=MountType.BIND, source="/run/dbus", target="/run/dbus", read_only=True
|
||||
)
|
||||
MOUNT_DEV = Mount(
|
||||
type=MountType.BIND.value, source="/dev", target="/dev", read_only=True
|
||||
MOUNT_DEV = DockerMount(
|
||||
type=MountType.BIND,
|
||||
source="/dev",
|
||||
target="/dev",
|
||||
read_only=True,
|
||||
bind_options=MountBindOptions(read_only_non_recursive=True),
|
||||
)
|
||||
MOUNT_DEV.setdefault("BindOptions", {})["ReadOnlyNonRecursive"] = True
|
||||
MOUNT_DOCKER = Mount(
|
||||
type=MountType.BIND.value,
|
||||
MOUNT_DOCKER = DockerMount(
|
||||
type=MountType.BIND,
|
||||
source="/run/docker.sock",
|
||||
target="/run/docker.sock",
|
||||
read_only=True,
|
||||
)
|
||||
MOUNT_MACHINE_ID = Mount(
|
||||
type=MountType.BIND.value,
|
||||
MOUNT_MACHINE_ID = DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=MACHINE_ID.as_posix(),
|
||||
target=MACHINE_ID.as_posix(),
|
||||
read_only=True,
|
||||
)
|
||||
MOUNT_UDEV = Mount(
|
||||
type=MountType.BIND.value, source="/run/udev", target="/run/udev", read_only=True
|
||||
MOUNT_UDEV = DockerMount(
|
||||
type=MountType.BIND, source="/run/udev", target="/run/udev", read_only=True
|
||||
)
|
||||
|
||||
PATH_PRIVATE_DATA = PurePath("/data")
|
||||
|
||||
@@ -2,13 +2,11 @@
|
||||
|
||||
import logging
|
||||
|
||||
from docker.types import Mount
|
||||
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..exceptions import DockerJobError
|
||||
from ..jobs.const import JobConcurrency
|
||||
from ..jobs.decorator import Job
|
||||
from .const import ENV_TIME, MOUNT_DBUS, MountType
|
||||
from .const import ENV_TIME, MOUNT_DBUS, DockerMount, MountType
|
||||
from .interface import DockerInterface
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
@@ -47,8 +45,8 @@ class DockerDNS(DockerInterface, CoreSysAttributes):
|
||||
security_opt=self.security_opt,
|
||||
environment={ENV_TIME: self.sys_timezone},
|
||||
mounts=[
|
||||
Mount(
|
||||
type=MountType.BIND.value,
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=self.sys_config.path_extern_dns.as_posix(),
|
||||
target="/config",
|
||||
read_only=False,
|
||||
|
||||
@@ -5,7 +5,6 @@ import logging
|
||||
import re
|
||||
|
||||
from awesomeversion import AwesomeVersion
|
||||
from docker.types import Mount
|
||||
|
||||
from ..const import LABEL_MACHINE
|
||||
from ..exceptions import DockerJobError
|
||||
@@ -26,6 +25,8 @@ from .const import (
|
||||
PATH_PUBLIC_CONFIG,
|
||||
PATH_SHARE,
|
||||
PATH_SSL,
|
||||
DockerMount,
|
||||
MountBindOptions,
|
||||
MountType,
|
||||
PropagationMode,
|
||||
)
|
||||
@@ -91,15 +92,15 @@ class DockerHomeAssistant(DockerInterface):
|
||||
)
|
||||
|
||||
@property
|
||||
def mounts(self) -> list[Mount]:
|
||||
def mounts(self) -> list[DockerMount]:
|
||||
"""Return mounts for container."""
|
||||
mounts = [
|
||||
MOUNT_DEV,
|
||||
MOUNT_DBUS,
|
||||
MOUNT_UDEV,
|
||||
# HA config folder
|
||||
Mount(
|
||||
type=MountType.BIND.value,
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=self.sys_config.path_extern_homeassistant.as_posix(),
|
||||
target=PATH_PUBLIC_CONFIG.as_posix(),
|
||||
read_only=False,
|
||||
@@ -111,41 +112,45 @@ class DockerHomeAssistant(DockerInterface):
|
||||
mounts.extend(
|
||||
[
|
||||
# All other folders
|
||||
Mount(
|
||||
type=MountType.BIND.value,
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=self.sys_config.path_extern_ssl.as_posix(),
|
||||
target=PATH_SSL.as_posix(),
|
||||
read_only=True,
|
||||
),
|
||||
Mount(
|
||||
type=MountType.BIND.value,
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=self.sys_config.path_extern_share.as_posix(),
|
||||
target=PATH_SHARE.as_posix(),
|
||||
read_only=False,
|
||||
propagation=PropagationMode.RSLAVE.value,
|
||||
bind_options=MountBindOptions(
|
||||
propagation=PropagationMode.RSLAVE
|
||||
),
|
||||
),
|
||||
Mount(
|
||||
type=MountType.BIND.value,
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=self.sys_config.path_extern_media.as_posix(),
|
||||
target=PATH_MEDIA.as_posix(),
|
||||
read_only=False,
|
||||
propagation=PropagationMode.RSLAVE.value,
|
||||
bind_options=MountBindOptions(
|
||||
propagation=PropagationMode.RSLAVE
|
||||
),
|
||||
),
|
||||
# Configuration audio
|
||||
Mount(
|
||||
type=MountType.BIND.value,
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=self.sys_homeassistant.path_extern_pulse.as_posix(),
|
||||
target="/etc/pulse/client.conf",
|
||||
read_only=True,
|
||||
),
|
||||
Mount(
|
||||
type=MountType.BIND.value,
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=self.sys_plugins.audio.path_extern_pulse.as_posix(),
|
||||
target="/run/audio",
|
||||
read_only=True,
|
||||
),
|
||||
Mount(
|
||||
type=MountType.BIND.value,
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=self.sys_plugins.audio.path_extern_asound.as_posix(),
|
||||
target="/etc/asound.conf",
|
||||
read_only=True,
|
||||
@@ -216,20 +221,20 @@ class DockerHomeAssistant(DockerInterface):
|
||||
init=True,
|
||||
entrypoint=[],
|
||||
mounts=[
|
||||
Mount(
|
||||
type=MountType.BIND.value,
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=self.sys_config.path_extern_homeassistant.as_posix(),
|
||||
target="/config",
|
||||
read_only=False,
|
||||
),
|
||||
Mount(
|
||||
type=MountType.BIND.value,
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=self.sys_config.path_extern_ssl.as_posix(),
|
||||
target="/ssl",
|
||||
read_only=True,
|
||||
),
|
||||
Mount(
|
||||
type=MountType.BIND.value,
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=self.sys_config.path_extern_share.as_posix(),
|
||||
target="/share",
|
||||
read_only=False,
|
||||
|
||||
@@ -461,7 +461,7 @@ class DockerInterface(JobGroup, ABC):
|
||||
"""Get docker container, returns None if not found."""
|
||||
try:
|
||||
return await self.sys_run_in_executor(
|
||||
self.sys_docker.containers.get, self.name
|
||||
self.sys_docker.containers_legacy.get, self.name
|
||||
)
|
||||
except docker.errors.NotFound:
|
||||
return None
|
||||
@@ -493,7 +493,7 @@ class DockerInterface(JobGroup, ABC):
|
||||
"""Attach to running Docker container."""
|
||||
with suppress(docker.errors.DockerException, requests.RequestException):
|
||||
docker_container = await self.sys_run_in_executor(
|
||||
self.sys_docker.containers.get, self.name
|
||||
self.sys_docker.containers_legacy.get, self.name
|
||||
)
|
||||
self._meta = docker_container.attrs
|
||||
self.sys_docker.monitor.watch_container(docker_container)
|
||||
@@ -533,8 +533,11 @@ class DockerInterface(JobGroup, ABC):
|
||||
"""Run Docker image."""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def _run(self, **kwargs) -> None:
|
||||
"""Run Docker image with retry inf necessary."""
|
||||
async def _run(self, *, name: str, **kwargs) -> None:
|
||||
"""Run Docker image with retry if necessary."""
|
||||
if not (image := self.image):
|
||||
raise ValueError(f"Cannot determine image to use to run {self.name}!")
|
||||
|
||||
if await self.is_running():
|
||||
return
|
||||
|
||||
@@ -543,16 +546,14 @@ class DockerInterface(JobGroup, ABC):
|
||||
|
||||
# Create & Run container
|
||||
try:
|
||||
docker_container = await self.sys_run_in_executor(
|
||||
self.sys_docker.run, self.image, **kwargs
|
||||
)
|
||||
container_metadata = await self.sys_docker.run(image, name=name, **kwargs)
|
||||
except DockerNotFound as err:
|
||||
# If image is missing, capture the exception as this shouldn't happen
|
||||
await async_capture_exception(err)
|
||||
raise
|
||||
|
||||
# Store metadata
|
||||
self._meta = docker_container.attrs
|
||||
self._meta = container_metadata
|
||||
|
||||
@Job(
|
||||
name="docker_interface_stop",
|
||||
|
||||
@@ -13,10 +13,12 @@ import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any, Final, Self, cast
|
||||
from typing import Any, Final, Literal, Self, cast
|
||||
|
||||
import aiodocker
|
||||
from aiodocker.containers import DockerContainers
|
||||
from aiodocker.images import DockerImages
|
||||
from aiodocker.types import JSONObject
|
||||
from aiohttp import ClientSession, ClientTimeout, UnixConnector
|
||||
import attr
|
||||
from awesomeversion import AwesomeVersion, AwesomeVersionCompareException
|
||||
@@ -49,7 +51,16 @@ from ..exceptions import (
|
||||
)
|
||||
from ..utils.common import FileConfiguration
|
||||
from ..validate import SCHEMA_DOCKER_CONFIG
|
||||
from .const import DOCKER_HUB, DOCKER_HUB_LEGACY, LABEL_MANAGED
|
||||
from .const import (
|
||||
DOCKER_HUB,
|
||||
DOCKER_HUB_LEGACY,
|
||||
LABEL_MANAGED,
|
||||
Capabilities,
|
||||
DockerMount,
|
||||
MountType,
|
||||
RestartPolicy,
|
||||
Ulimit,
|
||||
)
|
||||
from .monitor import DockerMonitor
|
||||
from .network import DockerNetwork
|
||||
from .utils import get_registry_from_image
|
||||
@@ -297,8 +308,13 @@ class DockerAPI(CoreSysAttributes):
|
||||
return self.docker.images
|
||||
|
||||
@property
|
||||
def containers(self) -> ContainerCollection:
|
||||
def containers(self) -> DockerContainers:
|
||||
"""Return API containers."""
|
||||
return self.docker.containers
|
||||
|
||||
@property
|
||||
def containers_legacy(self) -> ContainerCollection:
|
||||
"""Return API containers from Dockerpy."""
|
||||
return self.dockerpy.containers
|
||||
|
||||
@property
|
||||
@@ -331,50 +347,137 @@ class DockerAPI(CoreSysAttributes):
|
||||
"""Stop docker events monitor."""
|
||||
await self.monitor.unload()
|
||||
|
||||
def run(
|
||||
def _create_container_config(
|
||||
self,
|
||||
image: str,
|
||||
*,
|
||||
tag: str = "latest",
|
||||
dns: bool = True,
|
||||
ipv4: IPv4Address | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Container:
|
||||
"""Create a Docker container and run it.
|
||||
init: bool = False,
|
||||
hostname: str | None = None,
|
||||
detach: bool = True,
|
||||
security_opt: list[str] | None = None,
|
||||
restart_policy: dict[str, RestartPolicy] | None = None,
|
||||
extra_hosts: dict[str, IPv4Address] | None = None,
|
||||
environment: dict[str, str | None] | None = None,
|
||||
mounts: list[DockerMount] | None = None,
|
||||
ports: dict[str, str | int | None] | None = None,
|
||||
oom_score_adj: int | None = None,
|
||||
network_mode: Literal["host"] | None = None,
|
||||
privileged: bool = False,
|
||||
device_cgroup_rules: list[str] | None = None,
|
||||
tmpfs: dict[str, str] | None = None,
|
||||
entrypoint: list[str] | None = None,
|
||||
cap_add: list[Capabilities] | None = None,
|
||||
ulimits: list[Ulimit] | None = None,
|
||||
cpu_rt_runtime: int | None = None,
|
||||
stdin_open: bool = False,
|
||||
pid_mode: str | None = None,
|
||||
uts_mode: str | None = None,
|
||||
) -> JSONObject:
|
||||
"""Map kwargs to create container config.
|
||||
|
||||
Need run inside executor.
|
||||
This only covers the docker options we currently use. It is not intended
|
||||
to be exhaustive as its dockerpy equivalent was. We'll add to it as we
|
||||
make use of new feature.
|
||||
"""
|
||||
name: str | None = kwargs.get("name")
|
||||
network_mode: str | None = kwargs.get("network_mode")
|
||||
hostname: str | None = kwargs.get("hostname")
|
||||
# Set up host dependent config for container
|
||||
host_config: dict[str, Any] = {
|
||||
"NetworkMode": network_mode if network_mode else "default",
|
||||
"Init": init,
|
||||
"Privileged": privileged,
|
||||
}
|
||||
if security_opt:
|
||||
host_config["SecurityOpt"] = security_opt
|
||||
if restart_policy:
|
||||
host_config["RestartPolicy"] = restart_policy
|
||||
if extra_hosts:
|
||||
host_config["ExtraHosts"] = [f"{k}:{v}" for k, v in extra_hosts.items()]
|
||||
if mounts:
|
||||
host_config["Mounts"] = [mount.to_dict() for mount in mounts]
|
||||
if oom_score_adj is not None:
|
||||
host_config["OomScoreAdj"] = oom_score_adj
|
||||
if device_cgroup_rules:
|
||||
host_config["DeviceCgroupRules"] = device_cgroup_rules
|
||||
if tmpfs:
|
||||
host_config["Tmpfs"] = tmpfs
|
||||
if cap_add:
|
||||
host_config["CapAdd"] = cap_add
|
||||
if cpu_rt_runtime is not None:
|
||||
host_config["CPURealtimeRuntime"] = cpu_rt_runtime
|
||||
if pid_mode:
|
||||
host_config["PidMode"] = pid_mode
|
||||
if uts_mode:
|
||||
host_config["UtsMode"] = uts_mode
|
||||
if ulimits:
|
||||
host_config["Ulimits"] = [limit.to_dict() for limit in ulimits]
|
||||
|
||||
if "labels" not in kwargs:
|
||||
kwargs["labels"] = {}
|
||||
elif isinstance(kwargs["labels"], list):
|
||||
kwargs["labels"] = dict.fromkeys(kwargs["labels"], "")
|
||||
# Full container config
|
||||
config: dict[str, Any] = {
|
||||
"Image": f"{image}:{tag}",
|
||||
"Labels": {LABEL_MANAGED: ""},
|
||||
"OpenStdin": stdin_open,
|
||||
"StdinOnce": not detach and stdin_open,
|
||||
"AttachStdin": not detach and stdin_open,
|
||||
"AttachStdout": not detach,
|
||||
"AttachStderr": not detach,
|
||||
"HostConfig": host_config,
|
||||
}
|
||||
if hostname:
|
||||
config["Hostname"] = hostname
|
||||
if environment:
|
||||
config["Env"] = [
|
||||
env if val is None else f"{env}={val}"
|
||||
for env, val in environment.items()
|
||||
]
|
||||
if entrypoint:
|
||||
config["Entrypoint"] = entrypoint
|
||||
|
||||
kwargs["labels"][LABEL_MANAGED] = ""
|
||||
|
||||
# Setup DNS
|
||||
# Set up networking
|
||||
if dns:
|
||||
kwargs["dns"] = [str(self.network.dns)]
|
||||
kwargs["dns_search"] = [DNS_SUFFIX]
|
||||
host_config["Dns"] = [str(self.network.dns)]
|
||||
host_config["DnsSearch"] = [DNS_SUFFIX]
|
||||
# CoreDNS forward plug-in fails in ~6s, then fallback triggers.
|
||||
# However, the default timeout of glibc and musl is 5s. Increase
|
||||
# default timeout to make sure CoreDNS fallback is working
|
||||
# on first query.
|
||||
kwargs["dns_opt"] = ["timeout:10"]
|
||||
host_config["DnsOptions"] = ["timeout:10"]
|
||||
if hostname:
|
||||
kwargs["domainname"] = DNS_SUFFIX
|
||||
config["Domainname"] = DNS_SUFFIX
|
||||
|
||||
# Setup network
|
||||
if not network_mode:
|
||||
kwargs["network"] = None
|
||||
# Setup ports
|
||||
if ports:
|
||||
port_bindings = {
|
||||
port if "/" in port else f"{port}/tcp": [
|
||||
{"HostIp": "", "HostPort": str(host_port) if host_port else ""}
|
||||
]
|
||||
for port, host_port in ports.items()
|
||||
}
|
||||
config["ExposedPorts"] = {port: {} for port in port_bindings}
|
||||
host_config["PortBindings"] = port_bindings
|
||||
|
||||
return config
|
||||
|
||||
async def run(
|
||||
self,
|
||||
image: str,
|
||||
*,
|
||||
name: str,
|
||||
tag: str = "latest",
|
||||
hostname: str | None = None,
|
||||
mounts: list[DockerMount] | None = None,
|
||||
network_mode: Literal["host"] | None = None,
|
||||
ipv4: IPv4Address | None = None,
|
||||
**kwargs,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a Docker container and run it."""
|
||||
if not image or not name:
|
||||
raise ValueError("image, name and tag cannot be an empty string!")
|
||||
|
||||
# Setup cidfile and bind mount it
|
||||
cidfile_path = None
|
||||
if name:
|
||||
cidfile_path = self.coresys.config.path_cid_files / f"{name}.cid"
|
||||
cidfile_path = self.coresys.config.path_cid_files / f"{name}.cid"
|
||||
|
||||
def create_cidfile() -> None:
|
||||
# Remove the file/directory if it exists e.g. as a leftover from unclean shutdown
|
||||
# Note: Can be a directory if Docker auto-started container with restart policy
|
||||
# before Supervisor could write the CID file
|
||||
@@ -388,31 +491,37 @@ class DockerAPI(CoreSysAttributes):
|
||||
# from creating it as a directory if container auto-starts
|
||||
cidfile_path.touch()
|
||||
|
||||
extern_cidfile_path = (
|
||||
self.coresys.config.path_extern_cid_files / f"{name}.cid"
|
||||
)
|
||||
await self.sys_run_in_executor(create_cidfile)
|
||||
|
||||
# Bind mount to /run/cid in container
|
||||
if "volumes" not in kwargs:
|
||||
kwargs["volumes"] = {}
|
||||
kwargs["volumes"][str(extern_cidfile_path)] = {
|
||||
"bind": "/run/cid",
|
||||
"mode": "ro",
|
||||
}
|
||||
# Bind mount to /run/cid in container
|
||||
extern_cidfile_path = self.coresys.config.path_extern_cid_files / f"{name}.cid"
|
||||
cid_mount = DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=extern_cidfile_path.as_posix(),
|
||||
target="/run/cid",
|
||||
read_only=True,
|
||||
)
|
||||
if mounts is None:
|
||||
mounts = [cid_mount]
|
||||
else:
|
||||
mounts = [*mounts, cid_mount]
|
||||
|
||||
# Create container
|
||||
config = self._create_container_config(
|
||||
image,
|
||||
tag=tag,
|
||||
hostname=hostname,
|
||||
mounts=mounts,
|
||||
network_mode=network_mode,
|
||||
**kwargs,
|
||||
)
|
||||
try:
|
||||
container = self.containers.create(
|
||||
f"{image}:{tag}", use_config_proxy=False, **kwargs
|
||||
)
|
||||
if cidfile_path:
|
||||
with cidfile_path.open("w", encoding="ascii") as cidfile:
|
||||
cidfile.write(str(container.id))
|
||||
except docker_errors.NotFound as err:
|
||||
raise DockerNotFound(
|
||||
f"Image {image}:{tag} does not exist for {name}", _LOGGER.error
|
||||
) from err
|
||||
except docker_errors.DockerException as err:
|
||||
container = await self.containers.create(config, name=name)
|
||||
except aiodocker.DockerError as err:
|
||||
if err.status == HTTPStatus.NOT_FOUND:
|
||||
raise DockerNotFound(
|
||||
f"Image {image}:{tag} does not exist for {name}", _LOGGER.error
|
||||
) from err
|
||||
raise DockerAPIError(
|
||||
f"Can't create container from {name}: {err}", _LOGGER.error
|
||||
) from err
|
||||
@@ -421,43 +530,62 @@ class DockerAPI(CoreSysAttributes):
|
||||
f"Dockerd connection issue for {name}: {err}", _LOGGER.error
|
||||
) from err
|
||||
|
||||
# Attach network
|
||||
if not network_mode:
|
||||
alias = [hostname] if hostname else None
|
||||
try:
|
||||
self.network.attach_container(container, alias=alias, ipv4=ipv4)
|
||||
except DockerError:
|
||||
_LOGGER.warning("Can't attach %s to hassio-network!", name)
|
||||
else:
|
||||
with suppress(DockerError):
|
||||
self.network.detach_default_bridge(container)
|
||||
else:
|
||||
host_network: Network = self.dockerpy.networks.get(DOCKER_NETWORK_HOST)
|
||||
# Get container metadata
|
||||
try:
|
||||
container_attrs = await container.show()
|
||||
except aiodocker.DockerError as err:
|
||||
raise DockerAPIError(
|
||||
f"Can't inspect new container {name}: {err}", _LOGGER.error
|
||||
) from err
|
||||
except requests.RequestException as err:
|
||||
raise DockerRequestError(
|
||||
f"Dockerd connection issue for {name}: {err}", _LOGGER.error
|
||||
) from err
|
||||
|
||||
# Check if container is register on host
|
||||
# https://github.com/moby/moby/issues/23302
|
||||
if name and name in (
|
||||
val.get("Name")
|
||||
for val in host_network.attrs.get("Containers", {}).values()
|
||||
):
|
||||
with suppress(docker_errors.NotFound):
|
||||
host_network.disconnect(name, force=True)
|
||||
# Setup network and store container id in cidfile
|
||||
def setup_network_and_cidfile() -> None:
|
||||
# Write cidfile
|
||||
with cidfile_path.open("w", encoding="ascii") as cidfile:
|
||||
cidfile.write(str(container.id))
|
||||
|
||||
# Attach network
|
||||
if not network_mode:
|
||||
alias = [hostname] if hostname else None
|
||||
try:
|
||||
self.network.attach_container(
|
||||
container.id, name, alias=alias, ipv4=ipv4
|
||||
)
|
||||
except DockerError:
|
||||
_LOGGER.warning("Can't attach %s to hassio-network!", name)
|
||||
else:
|
||||
with suppress(DockerError):
|
||||
self.network.detach_default_bridge(container.id, name)
|
||||
else:
|
||||
host_network: Network = self.dockerpy.networks.get(DOCKER_NETWORK_HOST)
|
||||
|
||||
# Check if container is register on host
|
||||
# https://github.com/moby/moby/issues/23302
|
||||
if name and name in (
|
||||
val.get("Name")
|
||||
for val in host_network.attrs.get("Containers", {}).values()
|
||||
):
|
||||
with suppress(docker_errors.NotFound):
|
||||
host_network.disconnect(name, force=True)
|
||||
|
||||
await self.sys_run_in_executor(setup_network_and_cidfile)
|
||||
|
||||
# Run container
|
||||
try:
|
||||
container.start()
|
||||
except docker_errors.DockerException as err:
|
||||
await container.start()
|
||||
except aiodocker.DockerError as err:
|
||||
raise DockerAPIError(f"Can't start {name}: {err}", _LOGGER.error) from err
|
||||
except requests.RequestException as err:
|
||||
raise DockerRequestError(
|
||||
f"Dockerd connection issue for {name}: {err}", _LOGGER.error
|
||||
) from err
|
||||
|
||||
# Update metadata
|
||||
with suppress(docker_errors.DockerException, requests.RequestException):
|
||||
container.reload()
|
||||
|
||||
return container
|
||||
# Return metadata
|
||||
return container_attrs
|
||||
|
||||
async def pull_image(
|
||||
self,
|
||||
@@ -610,7 +738,9 @@ class DockerAPI(CoreSysAttributes):
|
||||
) -> bool:
|
||||
"""Return True if docker container exists in good state and is built from expected image."""
|
||||
try:
|
||||
docker_container = await self.sys_run_in_executor(self.containers.get, name)
|
||||
docker_container = await self.sys_run_in_executor(
|
||||
self.containers_legacy.get, name
|
||||
)
|
||||
docker_image = await self.images.inspect(f"{image}:{version}")
|
||||
except docker_errors.NotFound:
|
||||
return False
|
||||
@@ -639,7 +769,7 @@ class DockerAPI(CoreSysAttributes):
|
||||
) -> None:
|
||||
"""Stop/remove Docker container."""
|
||||
try:
|
||||
docker_container: Container = self.containers.get(name)
|
||||
docker_container: Container = self.containers_legacy.get(name)
|
||||
except docker_errors.NotFound:
|
||||
# Generally suppressed so we don't log this
|
||||
raise DockerNotFound() from None
|
||||
@@ -666,7 +796,7 @@ class DockerAPI(CoreSysAttributes):
|
||||
def start_container(self, name: str) -> None:
|
||||
"""Start Docker container."""
|
||||
try:
|
||||
docker_container: Container = self.containers.get(name)
|
||||
docker_container: Container = self.containers_legacy.get(name)
|
||||
except docker_errors.NotFound:
|
||||
raise DockerNotFound(
|
||||
f"{name} not found for starting up", _LOGGER.error
|
||||
@@ -685,7 +815,7 @@ class DockerAPI(CoreSysAttributes):
|
||||
def restart_container(self, name: str, timeout: int) -> None:
|
||||
"""Restart docker container."""
|
||||
try:
|
||||
container: Container = self.containers.get(name)
|
||||
container: Container = self.containers_legacy.get(name)
|
||||
except docker_errors.NotFound:
|
||||
raise DockerNotFound(
|
||||
f"Container {name} not found for restarting", _LOGGER.warning
|
||||
@@ -704,7 +834,7 @@ class DockerAPI(CoreSysAttributes):
|
||||
def container_logs(self, name: str, tail: int = 100) -> bytes:
|
||||
"""Return Docker logs of container."""
|
||||
try:
|
||||
docker_container: Container = self.containers.get(name)
|
||||
docker_container: Container = self.containers_legacy.get(name)
|
||||
except docker_errors.NotFound:
|
||||
raise DockerNotFound(
|
||||
f"Container {name} not found for logs", _LOGGER.warning
|
||||
@@ -724,7 +854,7 @@ class DockerAPI(CoreSysAttributes):
|
||||
def container_stats(self, name: str) -> dict[str, Any]:
|
||||
"""Read and return stats from container."""
|
||||
try:
|
||||
docker_container: Container = self.containers.get(name)
|
||||
docker_container: Container = self.containers_legacy.get(name)
|
||||
except docker_errors.NotFound:
|
||||
raise DockerNotFound(
|
||||
f"Container {name} not found for stats", _LOGGER.warning
|
||||
@@ -749,7 +879,7 @@ class DockerAPI(CoreSysAttributes):
|
||||
def container_run_inside(self, name: str, command: str) -> CommandReturn:
|
||||
"""Execute a command inside Docker container."""
|
||||
try:
|
||||
docker_container: Container = self.containers.get(name)
|
||||
docker_container: Container = self.containers_legacy.get(name)
|
||||
except docker_errors.NotFound:
|
||||
raise DockerNotFound(
|
||||
f"Container {name} not found for running command", _LOGGER.warning
|
||||
|
||||
@@ -7,7 +7,6 @@ import logging
|
||||
from typing import Self, cast
|
||||
|
||||
import docker
|
||||
from docker.models.containers import Container
|
||||
from docker.models.networks import Network
|
||||
import requests
|
||||
|
||||
@@ -220,7 +219,8 @@ class DockerNetwork:
|
||||
|
||||
def attach_container(
|
||||
self,
|
||||
container: Container,
|
||||
container_id: str,
|
||||
name: str,
|
||||
alias: list[str] | None = None,
|
||||
ipv4: IPv4Address | None = None,
|
||||
) -> None:
|
||||
@@ -233,15 +233,15 @@ class DockerNetwork:
|
||||
self.network.reload()
|
||||
|
||||
# Check stale Network
|
||||
if container.name and container.name in (
|
||||
if name in (
|
||||
val.get("Name") for val in self.network.attrs.get("Containers", {}).values()
|
||||
):
|
||||
self.stale_cleanup(container.name)
|
||||
self.stale_cleanup(name)
|
||||
|
||||
# Attach Network
|
||||
try:
|
||||
self.network.connect(
|
||||
container, aliases=alias, ipv4_address=str(ipv4) if ipv4 else None
|
||||
container_id, aliases=alias, ipv4_address=str(ipv4) if ipv4 else None
|
||||
)
|
||||
except (
|
||||
docker.errors.NotFound,
|
||||
@@ -250,7 +250,7 @@ class DockerNetwork:
|
||||
requests.RequestException,
|
||||
) as err:
|
||||
raise DockerError(
|
||||
f"Can't connect {container.name} to Supervisor network: {err}",
|
||||
f"Can't connect {name} to Supervisor network: {err}",
|
||||
_LOGGER.error,
|
||||
) from err
|
||||
|
||||
@@ -274,17 +274,20 @@ class DockerNetwork:
|
||||
) as err:
|
||||
raise DockerError(f"Can't find {name}: {err}", _LOGGER.error) from err
|
||||
|
||||
if container.id not in self.containers:
|
||||
self.attach_container(container, alias, ipv4)
|
||||
if not (container_id := container.id):
|
||||
raise DockerError(f"Received invalid metadata from docker for {name}")
|
||||
|
||||
def detach_default_bridge(self, container: Container) -> None:
|
||||
if container_id not in self.containers:
|
||||
self.attach_container(container_id, name, alias, ipv4)
|
||||
|
||||
def detach_default_bridge(self, container_id: str, name: str) -> None:
|
||||
"""Detach default Docker bridge.
|
||||
|
||||
Need run inside executor.
|
||||
"""
|
||||
try:
|
||||
default_network = self.docker.networks.get(DOCKER_NETWORK_DRIVER)
|
||||
default_network.disconnect(container)
|
||||
default_network.disconnect(container_id)
|
||||
except docker.errors.NotFound:
|
||||
pass
|
||||
except (
|
||||
@@ -293,7 +296,7 @@ class DockerNetwork:
|
||||
requests.RequestException,
|
||||
) as err:
|
||||
raise DockerError(
|
||||
f"Can't disconnect {container.name} from default network: {err}",
|
||||
f"Can't disconnect {name} from default network: {err}",
|
||||
_LOGGER.warning,
|
||||
) from err
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ class DockerSupervisor(DockerInterface):
|
||||
"""Attach to running docker container."""
|
||||
try:
|
||||
docker_container = await self.sys_run_in_executor(
|
||||
self.sys_docker.containers.get, self.name
|
||||
self.sys_docker.containers_legacy.get, self.name
|
||||
)
|
||||
except (docker.errors.DockerException, requests.RequestException) as err:
|
||||
raise DockerError() from err
|
||||
@@ -74,7 +74,8 @@ class DockerSupervisor(DockerInterface):
|
||||
_LOGGER.info("Connecting Supervisor to hassio-network")
|
||||
await self.sys_run_in_executor(
|
||||
self.sys_docker.network.attach_container,
|
||||
docker_container,
|
||||
docker_container.id,
|
||||
self.name,
|
||||
alias=["supervisor"],
|
||||
ipv4=self.sys_docker.network.supervisor,
|
||||
)
|
||||
@@ -90,7 +91,7 @@ class DockerSupervisor(DockerInterface):
|
||||
Need run inside executor.
|
||||
"""
|
||||
try:
|
||||
docker_container = self.sys_docker.containers.get(self.name)
|
||||
docker_container = self.sys_docker.containers_legacy.get(self.name)
|
||||
except (docker.errors.DockerException, requests.RequestException) as err:
|
||||
raise DockerError(
|
||||
f"Could not get Supervisor container for retag: {err}", _LOGGER.error
|
||||
@@ -118,7 +119,7 @@ class DockerSupervisor(DockerInterface):
|
||||
"""Update start tag to new version."""
|
||||
try:
|
||||
docker_container = await self.sys_run_in_executor(
|
||||
self.sys_docker.containers.get, self.name
|
||||
self.sys_docker.containers_legacy.get, self.name
|
||||
)
|
||||
docker_image = await self.sys_docker.images.inspect(f"{image}:{version!s}")
|
||||
except (
|
||||
|
||||
@@ -74,7 +74,9 @@ class EvaluateContainer(EvaluateBase):
|
||||
self._images.clear()
|
||||
|
||||
try:
|
||||
containers = await self.sys_run_in_executor(self.sys_docker.containers.list)
|
||||
containers = await self.sys_run_in_executor(
|
||||
self.sys_docker.containers_legacy.list
|
||||
)
|
||||
except (DockerException, RequestException) as err:
|
||||
_LOGGER.error("Corrupt docker overlayfs detect: %s", err)
|
||||
self.sys_resolution.create_issue(
|
||||
|
||||
@@ -227,7 +227,7 @@ async def test_listener_attached_on_install(
|
||||
container_collection.get.side_effect = DockerException()
|
||||
with (
|
||||
patch(
|
||||
"supervisor.docker.manager.DockerAPI.containers",
|
||||
"supervisor.docker.manager.DockerAPI.containers_legacy",
|
||||
new=PropertyMock(return_value=container_collection),
|
||||
),
|
||||
patch("pathlib.Path.is_dir", return_value=True),
|
||||
@@ -527,7 +527,7 @@ async def test_backup_with_pre_command_error(
|
||||
exc_type_raised: type[HassioError],
|
||||
) -> None:
|
||||
"""Test backing up an addon with error running pre command."""
|
||||
coresys.docker.containers.get.side_effect = container_get_side_effect
|
||||
coresys.docker.containers_legacy.get.side_effect = container_get_side_effect
|
||||
container.exec_run.side_effect = exec_run_side_effect
|
||||
|
||||
install_addon_ssh.path_data.mkdir()
|
||||
|
||||
@@ -679,7 +679,7 @@ async def test_addon_write_stdin_not_supported_error(api_client: TestClient):
|
||||
async def test_addon_rebuild_fails_error(api_client: TestClient, coresys: CoreSys):
|
||||
"""Test error when build fails during rebuild for addon."""
|
||||
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
||||
coresys.docker.containers.run.side_effect = DockerException("fail")
|
||||
coresys.docker.containers_legacy.run.side_effect = DockerException("fail")
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
|
||||
@@ -1201,10 +1201,8 @@ async def test_restore_homeassistant_adds_env(
|
||||
|
||||
assert docker.containers.create.call_args.kwargs["name"] == "homeassistant"
|
||||
assert (
|
||||
docker.containers.create.call_args.kwargs["environment"][
|
||||
"SUPERVISOR_RESTORE_JOB_ID"
|
||||
]
|
||||
== job.uuid
|
||||
f"SUPERVISOR_RESTORE_JOB_ID={job.uuid}"
|
||||
in docker.containers.create.call_args.args[0]["Env"]
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -35,9 +35,9 @@ async def test_api_core_logs(
|
||||
|
||||
async def test_api_stats(api_client: TestClient, coresys: CoreSys):
|
||||
"""Test stats."""
|
||||
coresys.docker.containers.get.return_value.status = "running"
|
||||
coresys.docker.containers.get.return_value.stats.return_value = load_json_fixture(
|
||||
"container_stats.json"
|
||||
coresys.docker.containers_legacy.get.return_value.status = "running"
|
||||
coresys.docker.containers_legacy.get.return_value.stats.return_value = (
|
||||
load_json_fixture("container_stats.json")
|
||||
)
|
||||
|
||||
resp = await api_client.get("/homeassistant/stats")
|
||||
@@ -138,14 +138,14 @@ async def test_api_rebuild(
|
||||
await api_client.post("/homeassistant/rebuild")
|
||||
|
||||
assert container.remove.call_count == 2
|
||||
container.start.assert_called_once()
|
||||
coresys.docker.containers.create.return_value.start.assert_called_once()
|
||||
assert not safe_mode_marker.exists()
|
||||
|
||||
with patch.object(HomeAssistantCore, "_block_till_run"):
|
||||
await api_client.post("/homeassistant/rebuild", json={"safe_mode": True})
|
||||
|
||||
assert container.remove.call_count == 4
|
||||
assert container.start.call_count == 2
|
||||
assert coresys.docker.containers.create.return_value.start.call_count == 2
|
||||
assert safe_mode_marker.exists()
|
||||
|
||||
|
||||
|
||||
@@ -412,9 +412,9 @@ async def test_api_progress_updates_supervisor_update(
|
||||
|
||||
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"
|
||||
coresys.docker.containers_legacy.get.return_value.status = "running"
|
||||
coresys.docker.containers_legacy.get.return_value.stats.return_value = (
|
||||
load_json_fixture("container_stats.json")
|
||||
)
|
||||
|
||||
resp = await api_client.get("/supervisor/stats")
|
||||
@@ -430,7 +430,7 @@ 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")
|
||||
coresys.docker.containers_legacy.get.side_effect = DockerException("fail")
|
||||
|
||||
resp = await api_client.get("/supervisor/stats")
|
||||
assert resp.status == 500
|
||||
|
||||
@@ -9,6 +9,7 @@ import subprocess
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
from aiodocker.containers import DockerContainer, DockerContainers
|
||||
from aiodocker.docker import DockerImages
|
||||
from aiohttp import ClientSession, web
|
||||
from aiohttp.test_utils import TestClient
|
||||
@@ -120,11 +121,13 @@ async def docker() -> DockerAPI:
|
||||
"Id": "test123",
|
||||
"RepoTags": ["ghcr.io/home-assistant/amd64-hassio-supervisor:latest"],
|
||||
}
|
||||
container_inspect = image_inspect | {"State": {"ExitCode": 0}}
|
||||
|
||||
with (
|
||||
patch("supervisor.docker.manager.DockerClient", return_value=MagicMock()),
|
||||
patch(
|
||||
"supervisor.docker.manager.DockerAPI.containers", return_value=MagicMock()
|
||||
"supervisor.docker.manager.DockerAPI.containers_legacy",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch("supervisor.docker.manager.DockerAPI.api", return_value=MagicMock()),
|
||||
patch("supervisor.docker.manager.DockerAPI.info", return_value=MagicMock()),
|
||||
@@ -136,6 +139,12 @@ async def docker() -> DockerAPI:
|
||||
return_value=(docker_images := MagicMock(spec=DockerImages))
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"supervisor.docker.manager.DockerAPI.containers",
|
||||
new=PropertyMock(
|
||||
return_value=(docker_containers := MagicMock(spec=DockerContainers))
|
||||
),
|
||||
),
|
||||
):
|
||||
docker_obj = await DockerAPI(MagicMock()).post_init()
|
||||
docker_obj.config._data = {"registries": {}}
|
||||
@@ -147,9 +156,15 @@ async def docker() -> DockerAPI:
|
||||
docker_images.import_image = AsyncMock(
|
||||
return_value=[{"stream": "Loaded image: test:latest\n"}]
|
||||
)
|
||||
|
||||
docker_images.pull.return_value = AsyncIterator([{}])
|
||||
|
||||
docker_containers.get.return_value = docker_container = MagicMock(
|
||||
spec=DockerContainer
|
||||
)
|
||||
docker_containers.list.return_value = [docker_container]
|
||||
docker_containers.create.return_value = docker_container
|
||||
docker_container.show.return_value = container_inspect
|
||||
|
||||
docker_obj.info.logging = "journald"
|
||||
docker_obj.info.storage = "overlay2"
|
||||
docker_obj.info.version = AwesomeVersion("1.0.0")
|
||||
@@ -790,7 +805,7 @@ async def docker_logs(docker: DockerAPI, supervisor_name) -> MagicMock:
|
||||
"""Mock log output for a container from docker."""
|
||||
container_mock = MagicMock()
|
||||
container_mock.logs.return_value = load_binary_fixture("logs_docker_container.txt")
|
||||
docker.containers.get.return_value = container_mock
|
||||
docker.containers_legacy.get.return_value = container_mock
|
||||
yield container_mock.logs
|
||||
|
||||
|
||||
@@ -824,7 +839,7 @@ async def os_available(request: pytest.FixtureRequest) -> None:
|
||||
@pytest.fixture
|
||||
async def mount_propagation(docker: DockerAPI, coresys: CoreSys) -> None:
|
||||
"""Mock supervisor connected to container with propagation set."""
|
||||
docker.containers.get.return_value = supervisor = MagicMock()
|
||||
docker.containers_legacy.get.return_value = supervisor = MagicMock()
|
||||
supervisor.attrs = {
|
||||
"Mounts": [
|
||||
{
|
||||
@@ -844,10 +859,11 @@ async def mount_propagation(docker: DockerAPI, coresys: CoreSys) -> None:
|
||||
@pytest.fixture
|
||||
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
|
||||
addon.status = "stopped"
|
||||
addon.attrs = {"State": {"ExitCode": 0}}
|
||||
attrs = {"State": {"ExitCode": 0}}
|
||||
docker.containers_legacy.get.return_value = addon = MagicMock(
|
||||
status="stopped", attrs=attrs
|
||||
)
|
||||
docker.containers.create.return_value.show.return_value = attrs
|
||||
yield addon
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"""Docker tests."""
|
||||
|
||||
from docker.types import Mount
|
||||
from supervisor.docker.const import DockerMount, MountBindOptions, MountType
|
||||
|
||||
# dev mount with equivalent of bind-recursive=writable specified via dict value
|
||||
DEV_MOUNT = Mount(type="bind", source="/dev", target="/dev", read_only=True)
|
||||
DEV_MOUNT["BindOptions"] = {"ReadOnlyNonRecursive": True}
|
||||
DEV_MOUNT = DockerMount(
|
||||
type=MountType.BIND,
|
||||
source="/dev",
|
||||
target="/dev",
|
||||
read_only=True,
|
||||
bind_options=MountBindOptions(read_only_non_recursive=True),
|
||||
)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""Test docker addon setup."""
|
||||
|
||||
import asyncio
|
||||
from http import HTTPStatus
|
||||
from ipaddress import IPv4Address
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, Mock, PropertyMock, patch
|
||||
|
||||
from docker.errors import NotFound
|
||||
from docker.types import Mount
|
||||
import aiodocker
|
||||
import pytest
|
||||
|
||||
from supervisor.addons import validate as vd
|
||||
@@ -18,6 +18,12 @@ from supervisor.const import BusEvent
|
||||
from supervisor.coresys import CoreSys
|
||||
from supervisor.dbus.agent.cgroup import CGroup
|
||||
from supervisor.docker.addon import DockerAddon
|
||||
from supervisor.docker.const import (
|
||||
DockerMount,
|
||||
MountBindOptions,
|
||||
MountType,
|
||||
PropagationMode,
|
||||
)
|
||||
from supervisor.docker.manager import DockerAPI
|
||||
from supervisor.exceptions import CoreDNSError, DockerNotFound
|
||||
from supervisor.hardware.data import Device
|
||||
@@ -80,8 +86,8 @@ def test_base_volumes_included(
|
||||
|
||||
# Data added as rw
|
||||
assert (
|
||||
Mount(
|
||||
type="bind",
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=docker_addon.addon.path_extern_data.as_posix(),
|
||||
target="/data",
|
||||
read_only=False,
|
||||
@@ -99,8 +105,8 @@ def test_addon_map_folder_defaults(
|
||||
)
|
||||
# Config added and is marked rw
|
||||
assert (
|
||||
Mount(
|
||||
type="bind",
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=coresys.config.path_extern_homeassistant.as_posix(),
|
||||
target="/config",
|
||||
read_only=False,
|
||||
@@ -110,8 +116,8 @@ def test_addon_map_folder_defaults(
|
||||
|
||||
# SSL added and defaults to ro
|
||||
assert (
|
||||
Mount(
|
||||
type="bind",
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=coresys.config.path_extern_ssl.as_posix(),
|
||||
target="/ssl",
|
||||
read_only=True,
|
||||
@@ -121,30 +127,30 @@ def test_addon_map_folder_defaults(
|
||||
|
||||
# Media added and propagation set
|
||||
assert (
|
||||
Mount(
|
||||
type="bind",
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=coresys.config.path_extern_media.as_posix(),
|
||||
target="/media",
|
||||
read_only=True,
|
||||
propagation="rslave",
|
||||
bind_options=MountBindOptions(propagation=PropagationMode.RSLAVE),
|
||||
)
|
||||
in docker_addon.mounts
|
||||
)
|
||||
|
||||
# Share added and propagation set
|
||||
assert (
|
||||
Mount(
|
||||
type="bind",
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=coresys.config.path_extern_share.as_posix(),
|
||||
target="/share",
|
||||
read_only=True,
|
||||
propagation="rslave",
|
||||
bind_options=MountBindOptions(propagation=PropagationMode.RSLAVE),
|
||||
)
|
||||
in docker_addon.mounts
|
||||
)
|
||||
|
||||
# Backup not added
|
||||
assert "/backup" not in [mount["Target"] for mount in docker_addon.mounts]
|
||||
assert "/backup" not in [mount.target for mount in docker_addon.mounts]
|
||||
|
||||
|
||||
def test_addon_map_homeassistant_folder(
|
||||
@@ -157,8 +163,8 @@ def test_addon_map_homeassistant_folder(
|
||||
|
||||
# Home Assistant config folder mounted to /homeassistant, not /config
|
||||
assert (
|
||||
Mount(
|
||||
type="bind",
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=coresys.config.path_extern_homeassistant.as_posix(),
|
||||
target="/homeassistant",
|
||||
read_only=True,
|
||||
@@ -177,8 +183,8 @@ def test_addon_map_addon_configs_folder(
|
||||
|
||||
# Addon configs folder included
|
||||
assert (
|
||||
Mount(
|
||||
type="bind",
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=coresys.config.path_extern_addon_configs.as_posix(),
|
||||
target="/addon_configs",
|
||||
read_only=True,
|
||||
@@ -197,8 +203,8 @@ def test_addon_map_addon_config_folder(
|
||||
|
||||
# Addon config folder included
|
||||
assert (
|
||||
Mount(
|
||||
type="bind",
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=docker_addon.addon.path_extern_config.as_posix(),
|
||||
target="/config",
|
||||
read_only=True,
|
||||
@@ -220,8 +226,8 @@ def test_addon_map_addon_config_folder_with_custom_target(
|
||||
|
||||
# Addon config folder included
|
||||
assert (
|
||||
Mount(
|
||||
type="bind",
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=docker_addon.addon.path_extern_config.as_posix(),
|
||||
target="/custom/target/path",
|
||||
read_only=False,
|
||||
@@ -240,8 +246,8 @@ def test_addon_map_data_folder_with_custom_target(
|
||||
|
||||
# Addon config folder included
|
||||
assert (
|
||||
Mount(
|
||||
type="bind",
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=docker_addon.addon.path_extern_data.as_posix(),
|
||||
target="/custom/data/path",
|
||||
read_only=False,
|
||||
@@ -260,8 +266,8 @@ def test_addon_ignore_on_config_map(
|
||||
|
||||
# Config added and is marked rw
|
||||
assert (
|
||||
Mount(
|
||||
type="bind",
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=coresys.config.path_extern_homeassistant.as_posix(),
|
||||
target="/config",
|
||||
read_only=False,
|
||||
@@ -271,11 +277,10 @@ def test_addon_ignore_on_config_map(
|
||||
|
||||
# Mount for addon's specific config folder omitted since config in map field
|
||||
assert (
|
||||
len([mount for mount in docker_addon.mounts if mount["Target"] == "/config"])
|
||||
== 1
|
||||
len([mount for mount in docker_addon.mounts if mount.target == "/config"]) == 1
|
||||
)
|
||||
# Home Assistant mount omitted since config in map field
|
||||
assert "/homeassistant" not in [mount["Target"] for mount in docker_addon.mounts]
|
||||
assert "/homeassistant" not in [mount.target for mount in docker_addon.mounts]
|
||||
|
||||
|
||||
def test_journald_addon(
|
||||
@@ -287,8 +292,8 @@ def test_journald_addon(
|
||||
)
|
||||
|
||||
assert (
|
||||
Mount(
|
||||
type="bind",
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source="/var/log/journal",
|
||||
target="/var/log/journal",
|
||||
read_only=True,
|
||||
@@ -296,8 +301,8 @@ def test_journald_addon(
|
||||
in docker_addon.mounts
|
||||
)
|
||||
assert (
|
||||
Mount(
|
||||
type="bind",
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source="/run/log/journal",
|
||||
target="/run/log/journal",
|
||||
read_only=True,
|
||||
@@ -314,7 +319,7 @@ def test_not_journald_addon(
|
||||
coresys, addonsdata_system, "basic-addon-config.json"
|
||||
)
|
||||
|
||||
assert "/var/log/journal" not in [mount["Target"] for mount in docker_addon.mounts]
|
||||
assert "/var/log/journal" not in [mount.target for mount in docker_addon.mounts]
|
||||
|
||||
|
||||
async def test_addon_run_docker_error(
|
||||
@@ -325,7 +330,9 @@ async def test_addon_run_docker_error(
|
||||
):
|
||||
"""Test docker error when addon is run."""
|
||||
await coresys.dbus.timedate.connect(coresys.dbus.bus)
|
||||
coresys.docker.containers.create.side_effect = NotFound("Missing")
|
||||
coresys.docker.containers.create.side_effect = aiodocker.DockerError(
|
||||
HTTPStatus.NOT_FOUND, {"message": "missing"}
|
||||
)
|
||||
docker_addon = get_docker_addon(
|
||||
coresys, addonsdata_system, "basic-addon-config.json"
|
||||
)
|
||||
|
||||
@@ -2,22 +2,24 @@
|
||||
|
||||
from ipaddress import IPv4Address
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from docker.types import Mount
|
||||
import pytest
|
||||
|
||||
from supervisor.coresys import CoreSys
|
||||
from supervisor.docker.const import DockerMount, MountType, Ulimit
|
||||
from supervisor.docker.manager import DockerAPI
|
||||
|
||||
from . import DEV_MOUNT
|
||||
|
||||
|
||||
async def test_start(coresys: CoreSys, tmp_supervisor_data: Path, path_extern):
|
||||
@pytest.mark.usefixtures("path_extern")
|
||||
async def test_start(coresys: CoreSys, tmp_supervisor_data: Path, container: MagicMock):
|
||||
"""Test starting audio plugin."""
|
||||
config_file = tmp_supervisor_data / "audio" / "pulse_audio.json"
|
||||
assert not config_file.exists()
|
||||
|
||||
with patch.object(DockerAPI, "run") as run:
|
||||
with patch.object(DockerAPI, "run", return_value=container.attrs) as run:
|
||||
await coresys.plugins.audio.start()
|
||||
|
||||
run.assert_called_once()
|
||||
@@ -26,21 +28,31 @@ async def test_start(coresys: CoreSys, tmp_supervisor_data: Path, path_extern):
|
||||
assert run.call_args.kwargs["hostname"] == "hassio-audio"
|
||||
assert run.call_args.kwargs["cap_add"] == ["SYS_NICE", "SYS_RESOURCE"]
|
||||
assert run.call_args.kwargs["ulimits"] == [
|
||||
{"Name": "rtprio", "Soft": 10, "Hard": 10}
|
||||
Ulimit(name="rtprio", soft=10, hard=10)
|
||||
]
|
||||
|
||||
assert run.call_args.kwargs["mounts"] == [
|
||||
DEV_MOUNT,
|
||||
Mount(
|
||||
type="bind",
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=coresys.config.path_extern_audio.as_posix(),
|
||||
target="/data",
|
||||
read_only=False,
|
||||
),
|
||||
Mount(type="bind", source="/run/dbus", target="/run/dbus", read_only=True),
|
||||
Mount(type="bind", source="/run/udev", target="/run/udev", read_only=True),
|
||||
Mount(
|
||||
type="bind",
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source="/run/dbus",
|
||||
target="/run/dbus",
|
||||
read_only=True,
|
||||
),
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source="/run/udev",
|
||||
target="/run/udev",
|
||||
read_only=True,
|
||||
),
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source="/etc/machine-id",
|
||||
target="/etc/machine-id",
|
||||
read_only=True,
|
||||
|
||||
@@ -2,20 +2,22 @@
|
||||
|
||||
from ipaddress import IPv4Address
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from docker.types import Mount
|
||||
import pytest
|
||||
|
||||
from supervisor.coresys import CoreSys
|
||||
from supervisor.docker.const import DockerMount, MountType
|
||||
from supervisor.docker.manager import DockerAPI
|
||||
|
||||
|
||||
async def test_start(coresys: CoreSys, tmp_supervisor_data: Path, path_extern):
|
||||
@pytest.mark.usefixtures("path_extern")
|
||||
async def test_start(coresys: CoreSys, tmp_supervisor_data: Path, container: MagicMock):
|
||||
"""Test starting dns plugin."""
|
||||
config_file = tmp_supervisor_data / "dns" / "coredns.json"
|
||||
assert not config_file.exists()
|
||||
|
||||
with patch.object(DockerAPI, "run") as run:
|
||||
with patch.object(DockerAPI, "run", return_value=container.attrs) as run:
|
||||
await coresys.plugins.dns.start()
|
||||
|
||||
run.assert_called_once()
|
||||
@@ -25,13 +27,18 @@ async def test_start(coresys: CoreSys, tmp_supervisor_data: Path, path_extern):
|
||||
assert run.call_args.kwargs["dns"] is False
|
||||
assert run.call_args.kwargs["oom_score_adj"] == -300
|
||||
assert run.call_args.kwargs["mounts"] == [
|
||||
Mount(
|
||||
type="bind",
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=coresys.config.path_extern_dns.as_posix(),
|
||||
target="/config",
|
||||
read_only=False,
|
||||
),
|
||||
Mount(type="bind", source="/run/dbus", target="/run/dbus", read_only=True),
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source="/run/dbus",
|
||||
target="/run/dbus",
|
||||
read_only=True,
|
||||
),
|
||||
]
|
||||
assert "volumes" not in run.call_args.kwargs
|
||||
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
"""Test Home Assistant container."""
|
||||
|
||||
from ipaddress import IPv4Address
|
||||
from pathlib import Path
|
||||
from unittest.mock import ANY, MagicMock, patch
|
||||
|
||||
from awesomeversion import AwesomeVersion
|
||||
from docker.types import Mount
|
||||
import pytest
|
||||
|
||||
from supervisor.coresys import CoreSys
|
||||
from supervisor.docker.const import (
|
||||
DockerMount,
|
||||
MountBindOptions,
|
||||
MountType,
|
||||
PropagationMode,
|
||||
)
|
||||
from supervisor.docker.homeassistant import DockerHomeAssistant
|
||||
from supervisor.docker.manager import DockerAPI
|
||||
from supervisor.homeassistant.const import LANDINGPAGE
|
||||
@@ -15,14 +20,13 @@ from supervisor.homeassistant.const import LANDINGPAGE
|
||||
from . import DEV_MOUNT
|
||||
|
||||
|
||||
async def test_homeassistant_start(
|
||||
coresys: CoreSys, tmp_supervisor_data: Path, path_extern
|
||||
):
|
||||
@pytest.mark.usefixtures("tmp_supervisor_data", "path_extern")
|
||||
async def test_homeassistant_start(coresys: CoreSys, container: MagicMock):
|
||||
"""Test starting homeassistant."""
|
||||
coresys.homeassistant.version = AwesomeVersion("2023.8.1")
|
||||
|
||||
with (
|
||||
patch.object(DockerAPI, "run") as run,
|
||||
patch.object(DockerAPI, "run", return_value=container.attrs) as run,
|
||||
patch.object(
|
||||
DockerHomeAssistant, "is_running", side_effect=[False, False, True]
|
||||
),
|
||||
@@ -50,54 +54,64 @@ async def test_homeassistant_start(
|
||||
}
|
||||
assert run.call_args.kwargs["mounts"] == [
|
||||
DEV_MOUNT,
|
||||
Mount(type="bind", source="/run/dbus", target="/run/dbus", read_only=True),
|
||||
Mount(type="bind", source="/run/udev", target="/run/udev", read_only=True),
|
||||
Mount(
|
||||
type="bind",
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source="/run/dbus",
|
||||
target="/run/dbus",
|
||||
read_only=True,
|
||||
),
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source="/run/udev",
|
||||
target="/run/udev",
|
||||
read_only=True,
|
||||
),
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=coresys.config.path_extern_homeassistant.as_posix(),
|
||||
target="/config",
|
||||
read_only=False,
|
||||
),
|
||||
Mount(
|
||||
type="bind",
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=coresys.config.path_extern_ssl.as_posix(),
|
||||
target="/ssl",
|
||||
read_only=True,
|
||||
),
|
||||
Mount(
|
||||
type="bind",
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=coresys.config.path_extern_share.as_posix(),
|
||||
target="/share",
|
||||
read_only=False,
|
||||
propagation="rslave",
|
||||
bind_options=MountBindOptions(propagation=PropagationMode.RSLAVE),
|
||||
),
|
||||
Mount(
|
||||
type="bind",
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=coresys.config.path_extern_media.as_posix(),
|
||||
target="/media",
|
||||
read_only=False,
|
||||
propagation="rslave",
|
||||
bind_options=MountBindOptions(propagation=PropagationMode.RSLAVE),
|
||||
),
|
||||
Mount(
|
||||
type="bind",
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=coresys.homeassistant.path_extern_pulse.as_posix(),
|
||||
target="/etc/pulse/client.conf",
|
||||
read_only=True,
|
||||
),
|
||||
Mount(
|
||||
type="bind",
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=coresys.plugins.audio.path_extern_pulse.as_posix(),
|
||||
target="/run/audio",
|
||||
read_only=True,
|
||||
),
|
||||
Mount(
|
||||
type="bind",
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=coresys.plugins.audio.path_extern_asound.as_posix(),
|
||||
target="/etc/asound.conf",
|
||||
read_only=True,
|
||||
),
|
||||
Mount(
|
||||
type="bind",
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source="/etc/machine-id",
|
||||
target="/etc/machine-id",
|
||||
read_only=True,
|
||||
@@ -106,15 +120,16 @@ async def test_homeassistant_start(
|
||||
assert "volumes" not in run.call_args.kwargs
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("tmp_supervisor_data", "path_extern")
|
||||
async def test_homeassistant_start_with_duplicate_log_file(
|
||||
coresys: CoreSys, tmp_supervisor_data: Path, path_extern
|
||||
coresys: CoreSys, container: MagicMock
|
||||
):
|
||||
"""Test starting homeassistant with duplicate_log_file enabled."""
|
||||
coresys.homeassistant.version = AwesomeVersion("2025.12.0")
|
||||
coresys.homeassistant.duplicate_log_file = True
|
||||
|
||||
with (
|
||||
patch.object(DockerAPI, "run") as run,
|
||||
patch.object(DockerAPI, "run", return_value=container.attrs) as run,
|
||||
patch.object(
|
||||
DockerHomeAssistant, "is_running", side_effect=[False, False, True]
|
||||
),
|
||||
@@ -128,14 +143,13 @@ async def test_homeassistant_start_with_duplicate_log_file(
|
||||
assert env["HA_DUPLICATE_LOG_FILE"] == "1"
|
||||
|
||||
|
||||
async def test_landingpage_start(
|
||||
coresys: CoreSys, tmp_supervisor_data: Path, path_extern
|
||||
):
|
||||
@pytest.mark.usefixtures("tmp_supervisor_data", "path_extern")
|
||||
async def test_landingpage_start(coresys: CoreSys, container: MagicMock):
|
||||
"""Test starting landingpage."""
|
||||
coresys.homeassistant.version = LANDINGPAGE
|
||||
|
||||
with (
|
||||
patch.object(DockerAPI, "run") as run,
|
||||
patch.object(DockerAPI, "run", return_value=container.attrs) as run,
|
||||
patch.object(DockerHomeAssistant, "is_running", return_value=False),
|
||||
):
|
||||
await coresys.homeassistant.core.start()
|
||||
@@ -160,16 +174,26 @@ async def test_landingpage_start(
|
||||
}
|
||||
assert run.call_args.kwargs["mounts"] == [
|
||||
DEV_MOUNT,
|
||||
Mount(type="bind", source="/run/dbus", target="/run/dbus", read_only=True),
|
||||
Mount(type="bind", source="/run/udev", target="/run/udev", read_only=True),
|
||||
Mount(
|
||||
type="bind",
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source="/run/dbus",
|
||||
target="/run/dbus",
|
||||
read_only=True,
|
||||
),
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source="/run/udev",
|
||||
target="/run/udev",
|
||||
read_only=True,
|
||||
),
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source=coresys.config.path_extern_homeassistant.as_posix(),
|
||||
target="/config",
|
||||
read_only=False,
|
||||
),
|
||||
Mount(
|
||||
type="bind",
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source="/etc/machine-id",
|
||||
target="/etc/machine-id",
|
||||
read_only=True,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Test Docker interface."""
|
||||
|
||||
import asyncio
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import ANY, AsyncMock, MagicMock, Mock, PropertyMock, call, patch
|
||||
@@ -148,7 +149,7 @@ async def test_current_state(
|
||||
container_collection = MagicMock()
|
||||
container_collection.get.return_value = Container(attrs)
|
||||
with patch(
|
||||
"supervisor.docker.manager.DockerAPI.containers",
|
||||
"supervisor.docker.manager.DockerAPI.containers_legacy",
|
||||
new=PropertyMock(return_value=container_collection),
|
||||
):
|
||||
assert await coresys.homeassistant.core.instance.current_state() == expected
|
||||
@@ -158,7 +159,7 @@ async def test_current_state_failures(coresys: CoreSys):
|
||||
"""Test failure states for current state."""
|
||||
container_collection = MagicMock()
|
||||
with patch(
|
||||
"supervisor.docker.manager.DockerAPI.containers",
|
||||
"supervisor.docker.manager.DockerAPI.containers_legacy",
|
||||
new=PropertyMock(return_value=container_collection),
|
||||
):
|
||||
container_collection.get.side_effect = NotFound("dne")
|
||||
@@ -211,7 +212,7 @@ async def test_attach_existing_container(
|
||||
container_collection.get.return_value = Container(attrs)
|
||||
with (
|
||||
patch(
|
||||
"supervisor.docker.manager.DockerAPI.containers",
|
||||
"supervisor.docker.manager.DockerAPI.containers_legacy",
|
||||
new=PropertyMock(return_value=container_collection),
|
||||
),
|
||||
patch.object(type(coresys.bus), "fire_event") as fire_event,
|
||||
@@ -253,7 +254,7 @@ async def test_attach_existing_container(
|
||||
|
||||
async def test_attach_container_failure(coresys: CoreSys):
|
||||
"""Test attach fails to find container but finds image."""
|
||||
coresys.docker.containers.get.side_effect = DockerException()
|
||||
coresys.docker.containers_legacy.get.side_effect = DockerException()
|
||||
coresys.docker.images.inspect.return_value.setdefault("Config", {})["Image"] = (
|
||||
"sha256:abc123"
|
||||
)
|
||||
@@ -271,7 +272,7 @@ async def test_attach_container_failure(coresys: CoreSys):
|
||||
|
||||
async def test_attach_total_failure(coresys: CoreSys):
|
||||
"""Test attach fails to find container or image."""
|
||||
coresys.docker.containers.get.side_effect = DockerException
|
||||
coresys.docker.containers_legacy.get.side_effect = DockerException
|
||||
coresys.docker.images.inspect.side_effect = aiodocker.DockerError(
|
||||
400, {"message": ""}
|
||||
)
|
||||
@@ -304,8 +305,10 @@ async def test_run_missing_image(
|
||||
tmp_supervisor_data: Path,
|
||||
):
|
||||
"""Test run captures the exception when image is missing."""
|
||||
coresys.docker.containers.create.side_effect = [NotFound("missing"), MagicMock()]
|
||||
container.status = "stopped"
|
||||
coresys.docker.containers.create.side_effect = [
|
||||
aiodocker.DockerError(HTTPStatus.NOT_FOUND, {"message": "missing"}),
|
||||
MagicMock(),
|
||||
]
|
||||
install_addon_ssh.data["image"] = "test_image"
|
||||
|
||||
with pytest.raises(DockerNotFound):
|
||||
|
||||
@@ -4,6 +4,7 @@ import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from aiodocker.containers import DockerContainer
|
||||
from docker.errors import APIError, DockerException, NotFound
|
||||
import pytest
|
||||
from requests import RequestException
|
||||
@@ -139,40 +140,38 @@ async def test_run_command_custom_stdout_stderr(docker: DockerAPI):
|
||||
assert result.output == b"output"
|
||||
|
||||
|
||||
async def test_run_container_with_cidfile(
|
||||
coresys: CoreSys, docker: DockerAPI, path_extern, tmp_supervisor_data
|
||||
):
|
||||
@pytest.mark.usefixtures("path_extern", "tmp_supervisor_data")
|
||||
async def test_run_container_with_cidfile(coresys: CoreSys, docker: DockerAPI):
|
||||
"""Test container creation with cidfile and bind mount."""
|
||||
# Mock container
|
||||
mock_container = MagicMock()
|
||||
mock_container.id = "test_container_id_12345"
|
||||
mock_container = MagicMock(spec=DockerContainer, id="test_container_id_12345")
|
||||
mock_container.show.return_value = mock_metadata = {"Id": mock_container.id}
|
||||
|
||||
container_name = "test_container"
|
||||
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.dockerpy.containers.run.return_value = mock_container
|
||||
docker.containers.create.return_value = mock_container
|
||||
|
||||
# Mock container creation
|
||||
with patch.object(
|
||||
docker.containers, "create", return_value=mock_container
|
||||
) as create_mock:
|
||||
# Execute run with a container name
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda kwrgs: docker.run(**kwrgs),
|
||||
{"image": "test_image", "tag": "latest", "name": container_name},
|
||||
)
|
||||
result = await docker.run("test_image", tag="latest", name=container_name)
|
||||
|
||||
# Check the container creation parameters
|
||||
create_mock.assert_called_once()
|
||||
kwargs = create_mock.call_args[1]
|
||||
create_config = create_mock.call_args.args[0]
|
||||
|
||||
assert "volumes" in kwargs
|
||||
assert str(extern_cidfile_path) in kwargs["volumes"]
|
||||
assert kwargs["volumes"][str(extern_cidfile_path)]["bind"] == "/run/cid"
|
||||
assert kwargs["volumes"][str(extern_cidfile_path)]["mode"] == "ro"
|
||||
assert "HostConfig" in create_config
|
||||
assert "Mounts" in create_config["HostConfig"]
|
||||
assert {
|
||||
"Type": "bind",
|
||||
"Source": str(extern_cidfile_path),
|
||||
"Target": "/run/cid",
|
||||
"ReadOnly": True,
|
||||
} in create_config["HostConfig"]["Mounts"]
|
||||
|
||||
# Verify container start was called
|
||||
mock_container.start.assert_called_once()
|
||||
@@ -181,16 +180,15 @@ async def test_run_container_with_cidfile(
|
||||
assert cidfile_path.exists()
|
||||
assert cidfile_path.read_text() == mock_container.id
|
||||
|
||||
assert result == mock_container
|
||||
assert result == mock_metadata
|
||||
|
||||
|
||||
async def test_run_container_with_leftover_cidfile(
|
||||
coresys: CoreSys, docker: DockerAPI, path_extern, tmp_supervisor_data
|
||||
):
|
||||
@pytest.mark.usefixtures("path_extern", "tmp_supervisor_data")
|
||||
async def test_run_container_with_leftover_cidfile(coresys: CoreSys, docker: DockerAPI):
|
||||
"""Test container creation removes leftover cidfile before creating new one."""
|
||||
# Mock container
|
||||
mock_container = MagicMock()
|
||||
mock_container.id = "test_container_id_new"
|
||||
mock_container = MagicMock(spec=DockerContainer, id="test_container_id_new")
|
||||
mock_container.show.return_value = mock_metadata = {"Id": mock_container.id}
|
||||
|
||||
container_name = "test_container"
|
||||
cidfile_path = coresys.config.path_cid_files / f"{container_name}.cid"
|
||||
@@ -203,12 +201,7 @@ async def test_run_container_with_leftover_cidfile(
|
||||
docker.containers, "create", return_value=mock_container
|
||||
) as create_mock:
|
||||
# Execute run with a container name
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda kwrgs: docker.run(**kwrgs),
|
||||
{"image": "test_image", "tag": "latest", "name": container_name},
|
||||
)
|
||||
result = await docker.run("test_image", tag="latest", name=container_name)
|
||||
|
||||
# Verify container was created
|
||||
create_mock.assert_called_once()
|
||||
@@ -217,7 +210,7 @@ async def test_run_container_with_leftover_cidfile(
|
||||
assert cidfile_path.exists()
|
||||
assert cidfile_path.read_text() == mock_container.id
|
||||
|
||||
assert result == mock_container
|
||||
assert result == mock_metadata
|
||||
|
||||
|
||||
async def test_stop_container_with_cidfile_cleanup(
|
||||
@@ -236,7 +229,7 @@ async def test_stop_container_with_cidfile_cleanup(
|
||||
|
||||
# Mock the containers.get method and cidfile cleanup
|
||||
with (
|
||||
patch.object(docker.containers, "get", return_value=mock_container),
|
||||
patch.object(docker.containers_legacy, "get", return_value=mock_container),
|
||||
):
|
||||
# Call stop_container with remove_container=True
|
||||
loop = asyncio.get_event_loop()
|
||||
@@ -263,7 +256,7 @@ async def test_stop_container_without_removal_no_cidfile_cleanup(docker: DockerA
|
||||
|
||||
# Mock the containers.get method and cidfile cleanup
|
||||
with (
|
||||
patch.object(docker.containers, "get", return_value=mock_container),
|
||||
patch.object(docker.containers_legacy, "get", return_value=mock_container),
|
||||
patch("pathlib.Path.unlink") as mock_unlink,
|
||||
):
|
||||
# Call stop_container with remove_container=False
|
||||
@@ -277,9 +270,8 @@ async def test_stop_container_without_removal_no_cidfile_cleanup(docker: DockerA
|
||||
mock_unlink.assert_not_called()
|
||||
|
||||
|
||||
async def test_cidfile_cleanup_handles_oserror(
|
||||
coresys: CoreSys, docker: DockerAPI, path_extern, tmp_supervisor_data
|
||||
):
|
||||
@pytest.mark.usefixtures("path_extern", "tmp_supervisor_data")
|
||||
async def test_cidfile_cleanup_handles_oserror(coresys: CoreSys, docker: DockerAPI):
|
||||
"""Test that cidfile cleanup handles OSError gracefully."""
|
||||
# Mock container
|
||||
mock_container = MagicMock()
|
||||
@@ -293,7 +285,7 @@ async def test_cidfile_cleanup_handles_oserror(
|
||||
|
||||
# Mock the containers.get method and cidfile cleanup to raise OSError
|
||||
with (
|
||||
patch.object(docker.containers, "get", return_value=mock_container),
|
||||
patch.object(docker.containers_legacy, "get", return_value=mock_container),
|
||||
patch("pathlib.Path.is_dir", return_value=False),
|
||||
patch("pathlib.Path.is_file", return_value=True),
|
||||
patch(
|
||||
@@ -311,8 +303,9 @@ async def test_cidfile_cleanup_handles_oserror(
|
||||
mock_unlink.assert_called_once_with(missing_ok=True)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("path_extern", "tmp_supervisor_data")
|
||||
async def test_run_container_with_leftover_cidfile_directory(
|
||||
coresys: CoreSys, docker: DockerAPI, path_extern, tmp_supervisor_data
|
||||
coresys: CoreSys, docker: DockerAPI
|
||||
):
|
||||
"""Test container creation removes leftover cidfile directory before creating new one.
|
||||
|
||||
@@ -321,8 +314,8 @@ async def test_run_container_with_leftover_cidfile_directory(
|
||||
the bind mount source as a directory.
|
||||
"""
|
||||
# Mock container
|
||||
mock_container = MagicMock()
|
||||
mock_container.id = "test_container_id_new"
|
||||
mock_container = MagicMock(spec=DockerContainer, id="test_container_id_new")
|
||||
mock_container.show.return_value = mock_metadata = {"Id": mock_container.id}
|
||||
|
||||
container_name = "test_container"
|
||||
cidfile_path = coresys.config.path_cid_files / f"{container_name}.cid"
|
||||
@@ -336,12 +329,7 @@ async def test_run_container_with_leftover_cidfile_directory(
|
||||
docker.containers, "create", return_value=mock_container
|
||||
) as create_mock:
|
||||
# Execute run with a container name
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda kwrgs: docker.run(**kwrgs),
|
||||
{"image": "test_image", "tag": "latest", "name": container_name},
|
||||
)
|
||||
result = await docker.run("test_image", tag="latest", name=container_name)
|
||||
|
||||
# Verify container was created
|
||||
create_mock.assert_called_once()
|
||||
@@ -351,7 +339,7 @@ async def test_run_container_with_leftover_cidfile_directory(
|
||||
assert cidfile_path.is_file()
|
||||
assert cidfile_path.read_text() == mock_container.id
|
||||
|
||||
assert result == mock_container
|
||||
assert result == mock_metadata
|
||||
|
||||
|
||||
async def test_repair(coresys: CoreSys, caplog: pytest.LogCaptureFixture):
|
||||
|
||||
@@ -120,7 +120,7 @@ async def test_unlabeled_container(coresys: CoreSys):
|
||||
}
|
||||
)
|
||||
with patch(
|
||||
"supervisor.docker.manager.DockerAPI.containers",
|
||||
"supervisor.docker.manager.DockerAPI.containers_legacy",
|
||||
new=PropertyMock(return_value=container_collection),
|
||||
):
|
||||
await coresys.homeassistant.core.instance.attach(AwesomeVersion("2022.7.3"))
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
"""Test Observer plugin container."""
|
||||
|
||||
from ipaddress import IPv4Address, ip_network
|
||||
from unittest.mock import patch
|
||||
|
||||
from docker.types import Mount
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from supervisor.coresys import CoreSys
|
||||
from supervisor.docker.const import DockerMount, MountType
|
||||
from supervisor.docker.manager import DockerAPI
|
||||
|
||||
|
||||
async def test_start(coresys: CoreSys):
|
||||
async def test_start(coresys: CoreSys, container: MagicMock):
|
||||
"""Test starting observer plugin."""
|
||||
with patch.object(DockerAPI, "run") as run:
|
||||
with patch.object(DockerAPI, "run", return_value=container.attrs) as run:
|
||||
await coresys.plugins.observer.start()
|
||||
|
||||
run.assert_called_once()
|
||||
@@ -28,8 +27,8 @@ async def test_start(coresys: CoreSys):
|
||||
)
|
||||
assert run.call_args.kwargs["ports"] == {"80/tcp": 4357}
|
||||
assert run.call_args.kwargs["mounts"] == [
|
||||
Mount(
|
||||
type="bind",
|
||||
DockerMount(
|
||||
type=MountType.BIND,
|
||||
source="/run/docker.sock",
|
||||
target="/run/docker.sock",
|
||||
read_only=True,
|
||||
|
||||
@@ -238,6 +238,7 @@ async def test_install_other_error(
|
||||
@pytest.mark.usefixtures("path_extern")
|
||||
async def test_start(
|
||||
coresys: CoreSys,
|
||||
container: MagicMock,
|
||||
container_exc: DockerException | None,
|
||||
image_exc: aiodocker.DockerError | None,
|
||||
remove_calls: list[call],
|
||||
@@ -245,8 +246,8 @@ async def test_start(
|
||||
"""Test starting Home Assistant."""
|
||||
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
|
||||
coresys.docker.containers_legacy.get.return_value.id = "123"
|
||||
coresys.docker.containers_legacy.get.side_effect = container_exc
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
@@ -254,7 +255,7 @@ async def test_start(
|
||||
"version",
|
||||
new=PropertyMock(return_value=AwesomeVersion("2023.7.0")),
|
||||
),
|
||||
patch.object(DockerAPI, "run") as run,
|
||||
patch.object(DockerAPI, "run", return_value=container.attrs) as run,
|
||||
patch.object(HomeAssistantCore, "_block_till_run") as block_till_run,
|
||||
):
|
||||
await coresys.homeassistant.core.start()
|
||||
@@ -268,17 +269,18 @@ async def test_start(
|
||||
assert run.call_args.kwargs["name"] == "homeassistant"
|
||||
assert run.call_args.kwargs["hostname"] == "homeassistant"
|
||||
|
||||
coresys.docker.containers.get.return_value.stop.assert_not_called()
|
||||
coresys.docker.containers_legacy.get.return_value.stop.assert_not_called()
|
||||
assert (
|
||||
coresys.docker.containers.get.return_value.remove.call_args_list == remove_calls
|
||||
coresys.docker.containers_legacy.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.inspect.return_value = {"Id": "123"}
|
||||
coresys.docker.containers.get.return_value.image.id = "123"
|
||||
coresys.docker.containers.get.return_value.status = "exited"
|
||||
coresys.docker.containers_legacy.get.return_value.image.id = "123"
|
||||
coresys.docker.containers_legacy.get.return_value.status = "exited"
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
@@ -291,29 +293,29 @@ async def test_start_existing_container(coresys: CoreSys, path_extern):
|
||||
await coresys.homeassistant.core.start()
|
||||
block_till_run.assert_called_once()
|
||||
|
||||
coresys.docker.containers.get.return_value.start.assert_called_once()
|
||||
coresys.docker.containers.get.return_value.stop.assert_not_called()
|
||||
coresys.docker.containers.get.return_value.remove.assert_not_called()
|
||||
coresys.docker.containers.get.return_value.run.assert_not_called()
|
||||
coresys.docker.containers_legacy.get.return_value.start.assert_called_once()
|
||||
coresys.docker.containers_legacy.get.return_value.stop.assert_not_called()
|
||||
coresys.docker.containers_legacy.get.return_value.remove.assert_not_called()
|
||||
coresys.docker.containers_legacy.get.return_value.run.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exists", [True, False])
|
||||
async def test_stop(coresys: CoreSys, exists: bool):
|
||||
"""Test stoppping Home Assistant."""
|
||||
if exists:
|
||||
coresys.docker.containers.get.return_value.status = "running"
|
||||
coresys.docker.containers_legacy.get.return_value.status = "running"
|
||||
else:
|
||||
coresys.docker.containers.get.side_effect = NotFound("missing")
|
||||
coresys.docker.containers_legacy.get.side_effect = NotFound("missing")
|
||||
|
||||
await coresys.homeassistant.core.stop()
|
||||
|
||||
coresys.docker.containers.get.return_value.remove.assert_not_called()
|
||||
coresys.docker.containers_legacy.get.return_value.remove.assert_not_called()
|
||||
if exists:
|
||||
coresys.docker.containers.get.return_value.stop.assert_called_once_with(
|
||||
coresys.docker.containers_legacy.get.return_value.stop.assert_called_once_with(
|
||||
timeout=260
|
||||
)
|
||||
else:
|
||||
coresys.docker.containers.get.return_value.stop.assert_not_called()
|
||||
coresys.docker.containers_legacy.get.return_value.stop.assert_not_called()
|
||||
|
||||
|
||||
async def test_restart(coresys: CoreSys):
|
||||
@@ -322,18 +324,20 @@ async def test_restart(coresys: CoreSys):
|
||||
await coresys.homeassistant.core.restart()
|
||||
block_till_run.assert_called_once()
|
||||
|
||||
coresys.docker.containers.get.return_value.restart.assert_called_once_with(
|
||||
coresys.docker.containers_legacy.get.return_value.restart.assert_called_once_with(
|
||||
timeout=260
|
||||
)
|
||||
coresys.docker.containers.get.return_value.stop.assert_not_called()
|
||||
coresys.docker.containers_legacy.get.return_value.stop.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("get_error", [NotFound("missing"), DockerException(), None])
|
||||
async def test_restart_failures(coresys: CoreSys, get_error: DockerException | None):
|
||||
"""Test restart fails when container missing or can't be restarted."""
|
||||
coresys.docker.containers.get.return_value.restart.side_effect = DockerException()
|
||||
coresys.docker.containers_legacy.get.return_value.restart.side_effect = (
|
||||
DockerException()
|
||||
)
|
||||
if get_error:
|
||||
coresys.docker.containers.get.side_effect = get_error
|
||||
coresys.docker.containers_legacy.get.side_effect = get_error
|
||||
|
||||
with pytest.raises(HomeAssistantError):
|
||||
await coresys.homeassistant.core.restart()
|
||||
@@ -352,10 +356,12 @@ async def test_stats_failures(
|
||||
coresys: CoreSys, get_error: DockerException | None, status: str
|
||||
):
|
||||
"""Test errors when getting stats."""
|
||||
coresys.docker.containers.get.return_value.status = status
|
||||
coresys.docker.containers.get.return_value.stats.side_effect = DockerException()
|
||||
coresys.docker.containers_legacy.get.return_value.status = status
|
||||
coresys.docker.containers_legacy.get.return_value.stats.side_effect = (
|
||||
DockerException()
|
||||
)
|
||||
if get_error:
|
||||
coresys.docker.containers.get.side_effect = get_error
|
||||
coresys.docker.containers_legacy.get.side_effect = get_error
|
||||
|
||||
with pytest.raises(HomeAssistantError):
|
||||
await coresys.homeassistant.core.stats()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Test base plugin functionality."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import ANY, MagicMock, Mock, PropertyMock, call, patch
|
||||
|
||||
from awesomeversion import AwesomeVersion
|
||||
@@ -159,15 +158,13 @@ async def test_plugin_watchdog(coresys: CoreSys, plugin: PluginBase) -> None:
|
||||
],
|
||||
indirect=["plugin"],
|
||||
)
|
||||
@pytest.mark.usefixtures("coresys", "tmp_supervisor_data", "path_extern")
|
||||
async def test_plugin_watchdog_max_failed_attempts(
|
||||
coresys: CoreSys,
|
||||
capture_exception: Mock,
|
||||
plugin: PluginBase,
|
||||
error: PluginError,
|
||||
container: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
tmp_supervisor_data: Path,
|
||||
path_extern,
|
||||
) -> None:
|
||||
"""Test plugin watchdog gives up after max failed attempts."""
|
||||
with patch.object(type(plugin.instance), "attach"):
|
||||
|
||||
@@ -76,7 +76,7 @@ async def test_check(
|
||||
docker: DockerAPI, coresys: CoreSys, install_addon_ssh: Addon, folder: str
|
||||
):
|
||||
"""Test check reports issue when containers have incorrect config."""
|
||||
docker.containers.get = _make_mock_container_get(
|
||||
docker.containers_legacy.get = _make_mock_container_get(
|
||||
["homeassistant", "hassio_audio", "addon_local_ssh"], folder
|
||||
)
|
||||
# Use state used in setup()
|
||||
@@ -132,7 +132,7 @@ async def test_check(
|
||||
assert await docker_config.approve_check()
|
||||
|
||||
# IF config issue is resolved, all issues are removed except the main one. Which will be removed if check isn't approved
|
||||
docker.containers.get = _make_mock_container_get([])
|
||||
docker.containers_legacy.get = _make_mock_container_get([])
|
||||
with patch.object(DockerInterface, "is_running", return_value=True):
|
||||
await coresys.plugins.load()
|
||||
await coresys.homeassistant.load()
|
||||
@@ -159,7 +159,7 @@ async def test_addon_volume_mount_not_flagged(
|
||||
] # No media/share
|
||||
|
||||
# Mock container that has VOLUME mount to media/share with wrong propagation
|
||||
docker.containers.get = _make_mock_container_get_with_volume_mount(
|
||||
docker.containers_legacy.get = _make_mock_container_get_with_volume_mount(
|
||||
["addon_local_ssh"], folder
|
||||
)
|
||||
|
||||
@@ -221,7 +221,7 @@ async def test_addon_configured_mount_still_flagged(
|
||||
out.attrs["Mounts"].append(mount)
|
||||
return out
|
||||
|
||||
docker.containers.get = mock_container_get
|
||||
docker.containers_legacy.get = mock_container_get
|
||||
|
||||
await coresys.core.set_state(CoreState.SETUP)
|
||||
with patch.object(DockerInterface, "is_running", return_value=True):
|
||||
@@ -275,7 +275,7 @@ async def test_addon_custom_target_path_flagged(
|
||||
out.attrs["Mounts"].append(mount)
|
||||
return out
|
||||
|
||||
docker.containers.get = mock_container_get
|
||||
docker.containers_legacy.get = mock_container_get
|
||||
|
||||
await coresys.core.set_state(CoreState.SETUP)
|
||||
with patch.object(DockerInterface, "is_running", return_value=True):
|
||||
|
||||
@@ -30,7 +30,7 @@ async def test_evaluation(coresys: CoreSys):
|
||||
assert container.reason not in coresys.resolution.unsupported
|
||||
assert UnhealthyReason.DOCKER not in coresys.resolution.unhealthy
|
||||
|
||||
coresys.docker.containers.list.return_value = [
|
||||
coresys.docker.containers_legacy.list.return_value = [
|
||||
_make_image_attr("armhfbuild/watchtower:latest"),
|
||||
_make_image_attr("concerco/watchtowerv6:10.0.2"),
|
||||
_make_image_attr("containrrr/watchtower:1.1"),
|
||||
@@ -47,7 +47,7 @@ async def test_evaluation(coresys: CoreSys):
|
||||
"pyouroboros/ouroboros:1.4.3",
|
||||
}
|
||||
|
||||
coresys.docker.containers.list.return_value = []
|
||||
coresys.docker.containers_legacy.list.return_value = []
|
||||
await container()
|
||||
assert container.reason not in coresys.resolution.unsupported
|
||||
|
||||
@@ -62,7 +62,7 @@ async def test_corrupt_docker(coresys: CoreSys):
|
||||
corrupt_docker = Issue(IssueType.CORRUPT_DOCKER, ContextType.SYSTEM)
|
||||
assert corrupt_docker not in coresys.resolution.issues
|
||||
|
||||
coresys.docker.containers.list.side_effect = DockerException
|
||||
coresys.docker.containers_legacy.list.side_effect = DockerException
|
||||
await container()
|
||||
assert corrupt_docker in coresys.resolution.issues
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ async def test_evaluation(coresys: CoreSys, install_addon_ssh: Addon):
|
||||
meta.attrs = observer_attrs if name == "hassio_observer" else addon_attrs
|
||||
return meta
|
||||
|
||||
coresys.docker.containers.get = get_container
|
||||
coresys.docker.containers_legacy.get = get_container
|
||||
await coresys.plugins.observer.instance.attach(TEST_VERSION)
|
||||
await install_addon_ssh.instance.attach(TEST_VERSION)
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ async def _mock_wait_for_container() -> None:
|
||||
|
||||
async def test_fixup(docker: DockerAPI, coresys: CoreSys, install_addon_ssh: Addon):
|
||||
"""Test fixup rebuilds addon's container."""
|
||||
docker.containers.get = make_mock_container_get("running")
|
||||
docker.containers_legacy.get = make_mock_container_get("running")
|
||||
|
||||
addon_execute_rebuild = FixupAddonExecuteRebuild(coresys)
|
||||
|
||||
@@ -61,7 +61,7 @@ async def test_fixup_stopped_core(
|
||||
):
|
||||
"""Test fixup just removes addon's container when it is stopped."""
|
||||
caplog.clear()
|
||||
docker.containers.get = make_mock_container_get("stopped")
|
||||
docker.containers_legacy.get = make_mock_container_get("stopped")
|
||||
addon_execute_rebuild = FixupAddonExecuteRebuild(coresys)
|
||||
|
||||
coresys.resolution.create_issue(
|
||||
@@ -76,7 +76,7 @@ async def test_fixup_stopped_core(
|
||||
|
||||
assert not coresys.resolution.issues
|
||||
assert not coresys.resolution.suggestions
|
||||
docker.containers.get("addon_local_ssh").remove.assert_called_once_with(
|
||||
docker.containers_legacy.get("addon_local_ssh").remove.assert_called_once_with(
|
||||
force=True, v=True
|
||||
)
|
||||
assert "Addon local_ssh is stopped" in caplog.text
|
||||
@@ -90,7 +90,7 @@ async def test_fixup_unknown_core(
|
||||
):
|
||||
"""Test fixup does nothing if addon's container has already been removed."""
|
||||
caplog.clear()
|
||||
docker.containers.get.side_effect = NotFound("")
|
||||
docker.containers_legacy.get.side_effect = NotFound("")
|
||||
addon_execute_rebuild = FixupAddonExecuteRebuild(coresys)
|
||||
|
||||
coresys.resolution.create_issue(
|
||||
|
||||
@@ -27,7 +27,7 @@ def make_mock_container_get(status: str):
|
||||
|
||||
async def test_fixup(docker: DockerAPI, coresys: CoreSys):
|
||||
"""Test fixup rebuilds core's container."""
|
||||
docker.containers.get = make_mock_container_get("running")
|
||||
docker.containers_legacy.get = make_mock_container_get("running")
|
||||
|
||||
core_execute_rebuild = FixupCoreExecuteRebuild(coresys)
|
||||
|
||||
@@ -51,7 +51,7 @@ async def test_fixup_stopped_core(
|
||||
):
|
||||
"""Test fixup just removes HA's container when it is stopped."""
|
||||
caplog.clear()
|
||||
docker.containers.get = make_mock_container_get("stopped")
|
||||
docker.containers_legacy.get = make_mock_container_get("stopped")
|
||||
core_execute_rebuild = FixupCoreExecuteRebuild(coresys)
|
||||
|
||||
coresys.resolution.create_issue(
|
||||
@@ -65,7 +65,7 @@ async def test_fixup_stopped_core(
|
||||
|
||||
assert not coresys.resolution.issues
|
||||
assert not coresys.resolution.suggestions
|
||||
docker.containers.get("homeassistant").remove.assert_called_once_with(
|
||||
docker.containers_legacy.get("homeassistant").remove.assert_called_once_with(
|
||||
force=True, v=True
|
||||
)
|
||||
assert "Home Assistant is stopped" in caplog.text
|
||||
@@ -76,7 +76,7 @@ async def test_fixup_unknown_core(
|
||||
):
|
||||
"""Test fixup does nothing if core's container has already been removed."""
|
||||
caplog.clear()
|
||||
docker.containers.get.side_effect = NotFound("")
|
||||
docker.containers_legacy.get.side_effect = NotFound("")
|
||||
core_execute_rebuild = FixupCoreExecuteRebuild(coresys)
|
||||
|
||||
coresys.resolution.create_issue(
|
||||
|
||||
@@ -28,7 +28,7 @@ def make_mock_container_get(status: str):
|
||||
@pytest.mark.parametrize("status", ["running", "stopped"])
|
||||
async def test_fixup(docker: DockerAPI, coresys: CoreSys, status: str):
|
||||
"""Test fixup rebuilds plugin's container regardless of current state."""
|
||||
docker.containers.get = make_mock_container_get(status)
|
||||
docker.containers_legacy.get = make_mock_container_get(status)
|
||||
|
||||
plugin_execute_rebuild = FixupPluginExecuteRebuild(coresys)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user