mirror of
https://github.com/home-assistant/supervisor.git
synced 2026-07-04 12:25:02 +01:00
dc6a77507b
* fix(docker): register hw listener and match by-id paths for options-based devices Two bugs caused a crash loop when a USB device re-enumerates to a different minor number (e.g. ttyACM0→ttyACM1) after a HAOS reboot: 1. _hw_listener was only registered when addon.static_devices was non-empty. Addons that expose a device via the options schema (e.g. Z-Wave JS `device:` option) never had the listener registered, so add_devices_allowed was never called when the device reappeared at a new minor. 2. _hardware_events matched only device.path and device.sysfs against static_devices. When static_devices (or the new options path) contains a by-id symlink, the match always failed because by-id paths live in device.links. Fix: extend the listener registration condition to also cover addon.devices (options-based), and expand the path-matching set to include device.links so by-id paths resolve correctly. For options-based devices, compare the incoming Device against addon.devices (which re-evaluates options.json against the live hardware list, picking up the new minor number automatically). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(docker): use option_device_paths for cheap by-id hw event matching Refactor _hardware_events to avoid per-event full options validation (including pwnd hashing). Introduce AppOptions.extract_device_paths and AppModel.option_device_paths to extract raw device paths from options without resolving against live hardware. Use set-intersection against {device.path, device.sysfs, *device.links} so by-id symlinks match correctly after re-enumeration for both static and options-based devices. Update test to use real schema/options setup and simulate a minor-number change (ttyACM0→ttyACM1) with a stable by-id symlink. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: improve hw listener test coverage and add policy check Address PR review feedback: - Add hardware policy check in _hardware_events to prevent bypassing access restrictions on hotplug events (follows same pattern as startup cgroup setup) - Fix test_app_options_device_hw_listener to properly simulate USB re-enumeration with different minor numbers (166:0 → 166:1) - Add test_app_options_device_policy_check to verify policy enforcement for options-based devices - Update TEST_HW_DEVICE with realistic major/minor attributes (166:0 for tty) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * test: mock HostFeature.OS_AGENT in hardware event tests The _hardware_events method has a @Job decorator with conditions=[JobCondition.OS_AGENT], which checks if HostFeature.OS_AGENT is in sys_host.features. Without mocking this, the job conditions fail and the hardware event handler is never invoked, causing add_devices_allowed to not be called and tests to fail. Add patch.object(type(coresys.host), "features", ...) to all four hardware event tests to ensure the OS_AGENT job condition is met. Fixes test failures: - test_app_new_device (all 6 parametrized cases) - test_app_new_device_no_haos - test_app_options_device_hw_listener - test_app_options_device_policy_check Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * test: fix TEST_DEV_PATH to match TEST_HW_DEVICE.path TEST_DEV_PATH was set to /dev/ttyAMA0 but TEST_HW_DEVICE.path is /dev/ttyACM0. This mismatch would cause the dev_path=TEST_DEV_PATH parametrized test cases to fail because the hardware event handler checks if the device path intersects with the app's allowed devices, and "/dev/ttyAMA0" != "/dev/ttyACM0". Update TEST_DEV_PATH from /dev/ttyAMA0 to /dev/ttyACM0 to match TEST_HW_DEVICE. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * test: mock option_device_paths in test_app_options_device_hw_listener The test sets up schema and options but option_device_paths property may not be working as expected in the test environment. Add an explicit mock for option_device_paths to ensure it returns the by-id path, guaranteeing that: 1. The hardware listener is registered (checks option_device_paths at registration) 2. The device path matching works correctly in _hardware_events This ensures the test properly validates that hardware events are processed for options-based devices after re-enumeration. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * test: make policy check test actually exercise the policy guard test_app_options_device_policy_check set the device option via persist["options"], but option_device_paths reads the merged options and does not pick that override up during the test, so it returned an empty set. The hardware event therefore failed the path-match guard and returned before reaching the allowed_for_access check. The assert_not_called() assertion then passed regardless of the policy outcome -- it would still pass if the policy guard were removed entirely. Mock option_device_paths to return the configured by-id path (mirroring test_app_options_device_hw_listener) so the event device matches and execution actually reaches the policy guard the test is meant to verify. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: add unit test for AppOptions.extract_device_paths The integration tests exercise extract_device_paths only through a mocked option_device_paths property, so the schema-walking logic introduced for the hardware-event matching had no direct coverage. Add a unit test that drives every schema shape the recursion handles -- flat, optional, filtered, list, nested dict and list of dicts -- and asserts that non-device options, unset keys and empty values are skipped, without requiring the devices to exist in hardware. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Stefan Agner <stefan@agner.ch>
710 lines
24 KiB
Python
710 lines
24 KiB
Python
"""Test docker app setup."""
|
|
|
|
from dataclasses import replace
|
|
from http import HTTPStatus
|
|
from ipaddress import IPv4Address
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch
|
|
|
|
import aiodocker
|
|
import attr
|
|
import pytest
|
|
|
|
from supervisor.apps import validate as vd
|
|
from supervisor.apps.app import App
|
|
from supervisor.apps.model import Data
|
|
from supervisor.apps.options import AppOptions
|
|
from supervisor.const import BusEvent
|
|
from supervisor.coresys import CoreSys
|
|
from supervisor.dbus.agent.cgroup import CGroup
|
|
from supervisor.docker.app import DockerApp
|
|
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
|
|
from supervisor.host.const import HostFeature
|
|
from supervisor.os.manager import OSManager
|
|
from supervisor.plugins.dns import PluginDns
|
|
from supervisor.resolution.const import ContextType, IssueType, SuggestionType
|
|
from supervisor.resolution.data import Issue, Suggestion
|
|
|
|
from ..common import load_json_fixture
|
|
from . import DEV_MOUNT
|
|
|
|
from tests.common import fire_bus_event
|
|
|
|
|
|
@pytest.fixture(name="addonsdata_system")
|
|
def fixture_addonsdata_system() -> dict[str, Data]:
|
|
"""Mock AppsData.system."""
|
|
with patch(
|
|
"supervisor.apps.data.AppsData.system", new_callable=PropertyMock
|
|
) as mock:
|
|
yield mock
|
|
|
|
|
|
@pytest.fixture(name="addonsdata_user", autouse=True)
|
|
def fixture_addonsdata_user() -> dict[str, Data]:
|
|
"""Mock AppsData.user."""
|
|
with patch("supervisor.apps.data.AppsData.user", new_callable=PropertyMock) as mock:
|
|
mock.return_value = MagicMock()
|
|
yield mock
|
|
|
|
|
|
def get_docker_app(
|
|
coresys: CoreSys,
|
|
addonsdata_system: dict[str, Data],
|
|
config_file: str | dict[str, Any],
|
|
) -> DockerApp:
|
|
"""Make and return docker app object."""
|
|
config = (
|
|
load_json_fixture(config_file) if isinstance(config_file, str) else config_file
|
|
)
|
|
config = vd.SCHEMA_APP_CONFIG(config)
|
|
slug = config.get("slug")
|
|
addonsdata_system.return_value = {slug: config}
|
|
|
|
app = App(coresys, config.get("slug"))
|
|
return DockerApp(coresys, app)
|
|
|
|
|
|
@pytest.mark.usefixtures("path_extern")
|
|
def test_base_volumes_included(coresys: CoreSys, addonsdata_system: dict[str, Data]):
|
|
"""Dev and data volumes always included."""
|
|
docker_app = get_docker_app(coresys, addonsdata_system, "basic-app-config.json")
|
|
|
|
# Dev added as ro with bind-recursive=writable option
|
|
assert DEV_MOUNT in docker_app.mounts
|
|
|
|
# Data added as rw
|
|
assert (
|
|
DockerMount(
|
|
type=MountType.BIND,
|
|
source=docker_app.app.path_extern_data.as_posix(),
|
|
target="/data",
|
|
read_only=False,
|
|
)
|
|
in docker_app.mounts
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("path_extern")
|
|
def test_app_map_folder_defaults(coresys: CoreSys, addonsdata_system: dict[str, Data]):
|
|
"""Validate defaults for mapped folders in apps."""
|
|
docker_app = get_docker_app(coresys, addonsdata_system, "basic-app-config.json")
|
|
# Config added and is marked rw
|
|
assert (
|
|
DockerMount(
|
|
type=MountType.BIND,
|
|
source=coresys.config.path_extern_homeassistant.as_posix(),
|
|
target="/config",
|
|
read_only=False,
|
|
)
|
|
in docker_app.mounts
|
|
)
|
|
|
|
# SSL added and defaults to ro
|
|
assert (
|
|
DockerMount(
|
|
type=MountType.BIND,
|
|
source=coresys.config.path_extern_ssl.as_posix(),
|
|
target="/ssl",
|
|
read_only=True,
|
|
)
|
|
in docker_app.mounts
|
|
)
|
|
|
|
# Media added and propagation set
|
|
assert (
|
|
DockerMount(
|
|
type=MountType.BIND,
|
|
source=coresys.config.path_extern_media.as_posix(),
|
|
target="/media",
|
|
read_only=True,
|
|
bind_options=MountBindOptions(propagation=PropagationMode.RSLAVE),
|
|
)
|
|
in docker_app.mounts
|
|
)
|
|
|
|
# Share added and propagation set
|
|
assert (
|
|
DockerMount(
|
|
type=MountType.BIND,
|
|
source=coresys.config.path_extern_share.as_posix(),
|
|
target="/share",
|
|
read_only=True,
|
|
bind_options=MountBindOptions(propagation=PropagationMode.RSLAVE),
|
|
)
|
|
in docker_app.mounts
|
|
)
|
|
|
|
# Backup not added
|
|
assert "/backup" not in [mount.target for mount in docker_app.mounts]
|
|
|
|
|
|
@pytest.mark.usefixtures("path_extern")
|
|
def test_app_map_homeassistant_folder(
|
|
coresys: CoreSys, addonsdata_system: dict[str, Data]
|
|
):
|
|
"""Test mounts for app which maps homeassistant folder."""
|
|
config = load_json_fixture("app-config-map-app_config.json")
|
|
config["map"].append("homeassistant_config")
|
|
docker_app = get_docker_app(coresys, addonsdata_system, config)
|
|
|
|
# Home Assistant config folder mounted to /homeassistant, not /config
|
|
assert (
|
|
DockerMount(
|
|
type=MountType.BIND,
|
|
source=coresys.config.path_extern_homeassistant.as_posix(),
|
|
target="/homeassistant",
|
|
read_only=True,
|
|
)
|
|
in docker_app.mounts
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("path_extern")
|
|
def test_app_map_app_configs_folder(
|
|
coresys: CoreSys, addonsdata_system: dict[str, Data]
|
|
):
|
|
"""Test mounts for app which maps app configs folder."""
|
|
config = load_json_fixture("app-config-map-app_config.json")
|
|
config["map"].append("all_addon_configs")
|
|
docker_app = get_docker_app(coresys, addonsdata_system, config)
|
|
|
|
# App configs folder included
|
|
assert (
|
|
DockerMount(
|
|
type=MountType.BIND,
|
|
source=coresys.config.path_extern_app_configs.as_posix(),
|
|
target="/addon_configs",
|
|
read_only=True,
|
|
)
|
|
in docker_app.mounts
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("path_extern")
|
|
def test_app_map_app_config_folder(
|
|
coresys: CoreSys, addonsdata_system: dict[str, Data]
|
|
):
|
|
"""Test mounts for app which maps its own config folder."""
|
|
docker_app = get_docker_app(
|
|
coresys, addonsdata_system, "app-config-map-app_config.json"
|
|
)
|
|
|
|
# App config folder included
|
|
assert (
|
|
DockerMount(
|
|
type=MountType.BIND,
|
|
source=docker_app.app.path_extern_config.as_posix(),
|
|
target="/config",
|
|
read_only=True,
|
|
)
|
|
in docker_app.mounts
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("path_extern")
|
|
def test_app_map_app_config_folder_with_custom_target(
|
|
coresys: CoreSys, addonsdata_system: dict[str, Data]
|
|
):
|
|
"""Test mounts for app which maps its own config folder and sets target path."""
|
|
config = load_json_fixture("app-config-map-app_config.json")
|
|
config["map"].remove("addon_config")
|
|
config["map"].append(
|
|
{"type": "addon_config", "read_only": False, "path": "/custom/target/path"}
|
|
)
|
|
docker_app = get_docker_app(coresys, addonsdata_system, config)
|
|
|
|
# App config folder included
|
|
assert (
|
|
DockerMount(
|
|
type=MountType.BIND,
|
|
source=docker_app.app.path_extern_config.as_posix(),
|
|
target="/custom/target/path",
|
|
read_only=False,
|
|
)
|
|
in docker_app.mounts
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("path_extern")
|
|
def test_app_map_data_folder_with_custom_target(
|
|
coresys: CoreSys, addonsdata_system: dict[str, Data]
|
|
):
|
|
"""Test mounts for app which sets target path for data folder."""
|
|
config = load_json_fixture("app-config-map-app_config.json")
|
|
config["map"].append({"type": "data", "path": "/custom/data/path"})
|
|
docker_app = get_docker_app(coresys, addonsdata_system, config)
|
|
|
|
# App config folder included
|
|
assert (
|
|
DockerMount(
|
|
type=MountType.BIND,
|
|
source=docker_app.app.path_extern_data.as_posix(),
|
|
target="/custom/data/path",
|
|
read_only=False,
|
|
)
|
|
in docker_app.mounts
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("path_extern")
|
|
def test_app_ignore_on_config_map(coresys: CoreSys, addonsdata_system: dict[str, Data]):
|
|
"""Test mounts for app don't include app config or homeassistant when config included."""
|
|
config = load_json_fixture("basic-app-config.json")
|
|
config["map"].extend(["addon_config", "homeassistant_config"])
|
|
docker_app = get_docker_app(coresys, addonsdata_system, config)
|
|
|
|
# Config added and is marked rw
|
|
assert (
|
|
DockerMount(
|
|
type=MountType.BIND,
|
|
source=coresys.config.path_extern_homeassistant.as_posix(),
|
|
target="/config",
|
|
read_only=False,
|
|
)
|
|
in docker_app.mounts
|
|
)
|
|
|
|
# Mount for app's specific config folder omitted since config in map field
|
|
assert len([mount for mount in docker_app.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_app.mounts]
|
|
|
|
|
|
@pytest.mark.usefixtures("path_extern")
|
|
def test_journald_app(coresys: CoreSys, addonsdata_system: dict[str, Data]):
|
|
"""Validate volume for journald option."""
|
|
docker_app = get_docker_app(coresys, addonsdata_system, "journald-app-config.json")
|
|
|
|
assert (
|
|
DockerMount(
|
|
type=MountType.BIND,
|
|
source="/var/log/journal",
|
|
target="/var/log/journal",
|
|
read_only=True,
|
|
)
|
|
in docker_app.mounts
|
|
)
|
|
assert (
|
|
DockerMount(
|
|
type=MountType.BIND,
|
|
source="/run/log/journal",
|
|
target="/run/log/journal",
|
|
read_only=True,
|
|
)
|
|
in docker_app.mounts
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("path_extern")
|
|
def test_not_journald_app(coresys: CoreSys, addonsdata_system: dict[str, Data]):
|
|
"""Validate journald option defaults off."""
|
|
docker_app = get_docker_app(coresys, addonsdata_system, "basic-app-config.json")
|
|
|
|
assert "/var/log/journal" not in [mount.target for mount in docker_app.mounts]
|
|
|
|
|
|
@pytest.mark.usefixtures("path_extern", "tmp_supervisor_data")
|
|
async def test_app_run_docker_error(
|
|
coresys: CoreSys, addonsdata_system: dict[str, Data]
|
|
):
|
|
"""Test docker error when app is run."""
|
|
await coresys.dbus.timedate.connect(coresys.dbus.bus)
|
|
coresys.docker.containers.create.side_effect = aiodocker.DockerError(
|
|
HTTPStatus.NOT_FOUND, {"message": "missing"}
|
|
)
|
|
docker_app = get_docker_app(coresys, addonsdata_system, "basic-app-config.json")
|
|
|
|
with (
|
|
patch.object(DockerApp, "stop"),
|
|
patch.object(
|
|
AppOptions, "validate", new=PropertyMock(return_value=lambda _: None)
|
|
),
|
|
pytest.raises(DockerNotFound),
|
|
):
|
|
await docker_app.run()
|
|
|
|
assert (
|
|
Issue(IssueType.MISSING_IMAGE, ContextType.ADDON, reference="test_addon")
|
|
in coresys.resolution.issues
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("path_extern", "tmp_supervisor_data")
|
|
async def test_app_run_add_host_error(
|
|
coresys: CoreSys, addonsdata_system: dict[str, Data], capture_exception: Mock
|
|
):
|
|
"""Test error adding host when app is run."""
|
|
await coresys.dbus.timedate.connect(coresys.dbus.bus)
|
|
docker_app = get_docker_app(coresys, addonsdata_system, "basic-app-config.json")
|
|
|
|
with (
|
|
patch.object(DockerApp, "stop"),
|
|
patch.object(
|
|
AppOptions, "validate", new=PropertyMock(return_value=lambda _: None)
|
|
),
|
|
patch.object(PluginDns, "add_host", side_effect=(err := CoreDNSError())),
|
|
):
|
|
await docker_app.run()
|
|
|
|
capture_exception.assert_called_once_with(err)
|
|
|
|
|
|
async def test_app_stop_delete_host_error(
|
|
coresys: CoreSys, addonsdata_system: dict[str, Data], capture_exception: Mock
|
|
):
|
|
"""Test error deleting host when app is stopped."""
|
|
docker_app = get_docker_app(coresys, addonsdata_system, "basic-app-config.json")
|
|
|
|
with (
|
|
patch.object(
|
|
DockerApp,
|
|
"ip_address",
|
|
new=PropertyMock(return_value=IPv4Address("172.30.33.1")),
|
|
),
|
|
patch.object(PluginDns, "delete_host", side_effect=(err := CoreDNSError())),
|
|
):
|
|
await docker_app.stop()
|
|
|
|
capture_exception.assert_called_once_with(err)
|
|
|
|
|
|
TEST_DEV_PATH = "/dev/ttyACM0"
|
|
TEST_SYSFS_PATH = "/sys/devices/platform/soc/ffe09000.usb/ff500000.usb/xhci-hcd.0.auto/usb1/1-1/1-1.1/1-1.1:1.0/tty/ttyACM0"
|
|
TEST_HW_DEVICE = Device(
|
|
name="ttyACM0",
|
|
path=Path("/dev/ttyACM0"),
|
|
sysfs=Path(
|
|
"/sys/devices/platform/soc/ffe09000.usb/ff500000.usb/xhci-hcd.0.auto/usb1/1-1/1-1.1/1-1.1:1.0/tty/ttyACM0"
|
|
),
|
|
subsystem="tty",
|
|
parent=Path(
|
|
"/sys/devices/platform/soc/ffe09000.usb/ff500000.usb/xhci-hcd.0.auto/usb1/1-1/1-1.1/1-1.1:1.0"
|
|
),
|
|
links=[
|
|
Path(
|
|
"/dev/serial/by-id/usb-Texas_Instruments_TI_CC2531_USB_CDC___0X0123456789ABCDEF-if00"
|
|
),
|
|
Path("/dev/serial/by-path/platform-xhci-hcd.0.auto-usb-0:1.1:1.0"),
|
|
Path("/dev/serial/by-path/platform-xhci-hcd.0.auto-usbv2-0:1.1:1.0"),
|
|
],
|
|
attributes={"MAJOR": "166", "MINOR": "0"},
|
|
children=[],
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("path_extern", "tmp_supervisor_data")
|
|
@pytest.mark.parametrize(
|
|
("dev_path", "cgroup", "is_os"),
|
|
[
|
|
(TEST_DEV_PATH, "1", True),
|
|
(TEST_SYSFS_PATH, "1", True),
|
|
(TEST_DEV_PATH, "1", False),
|
|
(TEST_SYSFS_PATH, "1", False),
|
|
(TEST_DEV_PATH, "2", True),
|
|
(TEST_SYSFS_PATH, "2", True),
|
|
],
|
|
)
|
|
async def test_app_new_device(
|
|
coresys: CoreSys,
|
|
install_app_ssh: App,
|
|
container: MagicMock,
|
|
docker: DockerAPI,
|
|
dev_path: str,
|
|
cgroup: str,
|
|
is_os: bool,
|
|
):
|
|
"""Test new device that is listed in static devices."""
|
|
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
|
install_app_ssh.data["devices"] = [dev_path]
|
|
container.id = 123
|
|
docker._info = replace(docker.info, cgroup=cgroup) # pylint: disable=protected-access
|
|
|
|
with (
|
|
patch.object(App, "write_options"),
|
|
patch.object(OSManager, "available", new=PropertyMock(return_value=is_os)),
|
|
patch.object(
|
|
type(coresys.host),
|
|
"features",
|
|
new=PropertyMock(return_value=[HostFeature.OS_AGENT]),
|
|
),
|
|
patch.object(
|
|
CGroup, "add_devices_allowed", new_callable=AsyncMock
|
|
) as add_devices,
|
|
):
|
|
await install_app_ssh.start()
|
|
|
|
await fire_bus_event(
|
|
coresys,
|
|
BusEvent.HARDWARE_NEW_DEVICE,
|
|
TEST_HW_DEVICE,
|
|
)
|
|
|
|
add_devices.assert_called_once_with(123, "c 166:0 rwm")
|
|
|
|
|
|
@pytest.mark.usefixtures("path_extern", "tmp_supervisor_data")
|
|
@pytest.mark.parametrize("dev_path", [TEST_DEV_PATH, TEST_SYSFS_PATH])
|
|
async def test_app_new_device_no_haos(
|
|
coresys: CoreSys, install_app_ssh: App, docker: DockerAPI, dev_path: str
|
|
):
|
|
"""Test new device that is listed in static devices on non HAOS system with CGroup V2."""
|
|
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
|
install_app_ssh.data["devices"] = [dev_path]
|
|
docker._info = replace(docker.info, cgroup="2") # pylint: disable=protected-access
|
|
|
|
with (
|
|
patch.object(App, "write_options"),
|
|
patch.object(OSManager, "available", new=PropertyMock(return_value=False)),
|
|
patch.object(
|
|
type(coresys.host),
|
|
"features",
|
|
new=PropertyMock(return_value=[HostFeature.OS_AGENT]),
|
|
),
|
|
patch.object(
|
|
CGroup, "add_devices_allowed", new_callable=AsyncMock
|
|
) as add_devices,
|
|
):
|
|
await install_app_ssh.start()
|
|
|
|
await fire_bus_event(
|
|
coresys,
|
|
BusEvent.HARDWARE_NEW_DEVICE,
|
|
TEST_HW_DEVICE,
|
|
)
|
|
|
|
add_devices.assert_not_called()
|
|
|
|
# Issue added with hardware event since access cannot be added dynamically
|
|
assert install_app_ssh.device_access_missing_issue in coresys.resolution.issues
|
|
assert (
|
|
Suggestion(
|
|
SuggestionType.EXECUTE_RESTART, ContextType.ADDON, reference="local_ssh"
|
|
)
|
|
in coresys.resolution.suggestions
|
|
)
|
|
|
|
# Stopping and removing the container clears it as access granted on next start
|
|
await install_app_ssh.stop()
|
|
assert coresys.resolution.issues == []
|
|
assert coresys.resolution.suggestions == []
|
|
|
|
|
|
async def test_ulimits_integration(coresys: CoreSys, install_app_ssh: App):
|
|
"""Test ulimits integration with Docker app."""
|
|
docker_app = DockerApp(coresys, install_app_ssh)
|
|
|
|
# Test default case (no ulimits, no realtime)
|
|
assert docker_app.ulimits is None
|
|
|
|
# Test with realtime enabled (should have built-in ulimits)
|
|
install_app_ssh.data["realtime"] = True
|
|
ulimits = docker_app.ulimits
|
|
assert ulimits is not None
|
|
assert len(ulimits) == 2
|
|
# Check for rtprio limit
|
|
rtprio_limit = next((u for u in ulimits if u.name == "rtprio"), None)
|
|
assert rtprio_limit is not None
|
|
assert rtprio_limit.soft == 90
|
|
assert rtprio_limit.hard == 99
|
|
# Check for memlock limit
|
|
memlock_limit = next((u for u in ulimits if u.name == "memlock"), None)
|
|
assert memlock_limit is not None
|
|
assert memlock_limit.soft == 128 * 1024 * 1024
|
|
assert memlock_limit.hard == 128 * 1024 * 1024
|
|
|
|
# Test with configurable ulimits (simple format)
|
|
install_app_ssh.data["realtime"] = False
|
|
install_app_ssh.data["ulimits"] = {"nofile": 65535, "nproc": 32768}
|
|
ulimits = docker_app.ulimits
|
|
assert ulimits is not None
|
|
assert len(ulimits) == 2
|
|
|
|
nofile_limit = next((u for u in ulimits if u.name == "nofile"), None)
|
|
assert nofile_limit is not None
|
|
assert nofile_limit.soft == 65535
|
|
assert nofile_limit.hard == 65535
|
|
|
|
nproc_limit = next((u for u in ulimits if u.name == "nproc"), None)
|
|
assert nproc_limit is not None
|
|
assert nproc_limit.soft == 32768
|
|
assert nproc_limit.hard == 32768
|
|
|
|
# Test with configurable ulimits (detailed format)
|
|
install_app_ssh.data["ulimits"] = {
|
|
"nofile": {"soft": 20000, "hard": 40000},
|
|
"memlock": {"soft": 67108864, "hard": 134217728},
|
|
}
|
|
ulimits = docker_app.ulimits
|
|
assert ulimits is not None
|
|
assert len(ulimits) == 2
|
|
|
|
nofile_limit = next((u for u in ulimits if u.name == "nofile"), None)
|
|
assert nofile_limit is not None
|
|
assert nofile_limit.soft == 20000
|
|
assert nofile_limit.hard == 40000
|
|
|
|
memlock_limit = next((u for u in ulimits if u.name == "memlock"), None)
|
|
assert memlock_limit is not None
|
|
assert memlock_limit.soft == 67108864
|
|
assert memlock_limit.hard == 134217728
|
|
|
|
# Test mixed format and realtime (realtime + custom ulimits)
|
|
install_app_ssh.data["realtime"] = True
|
|
install_app_ssh.data["ulimits"] = {
|
|
"nofile": 65535,
|
|
"core": {"soft": 0, "hard": 0}, # Disable core dumps
|
|
}
|
|
ulimits = docker_app.ulimits
|
|
assert ulimits is not None
|
|
assert (
|
|
len(ulimits) == 4
|
|
) # rtprio, memlock (from realtime) + nofile, core (from config)
|
|
|
|
# Check realtime limits still present
|
|
rtprio_limit = next((u for u in ulimits if u.name == "rtprio"), None)
|
|
assert rtprio_limit is not None
|
|
|
|
# Check custom limits added
|
|
nofile_limit = next((u for u in ulimits if u.name == "nofile"), None)
|
|
assert nofile_limit is not None
|
|
assert nofile_limit.soft == 65535
|
|
assert nofile_limit.hard == 65535
|
|
|
|
core_limit = next((u for u in ulimits if u.name == "core"), None)
|
|
assert core_limit is not None
|
|
assert core_limit.soft == 0
|
|
assert core_limit.hard == 0
|
|
|
|
|
|
@pytest.mark.usefixtures("path_extern", "tmp_supervisor_data")
|
|
async def test_app_options_device_hw_listener(
|
|
coresys: CoreSys,
|
|
install_app_ssh: App,
|
|
container: MagicMock,
|
|
docker: DockerAPI,
|
|
):
|
|
"""Test hw_listener fires for a by-id option device after re-enumeration.
|
|
|
|
Scenario: the user picks a by-id path (stable symlink) in the add-on options.
|
|
On USB re-enumeration the kernel assigns a new device node (minor number
|
|
changes, e.g. ttyACM0 → ttyACM1) but the by-id symlink remains the same.
|
|
The hardware event must still be processed and cgroup permissions updated.
|
|
"""
|
|
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
|
install_app_ssh.data["devices"] = [] # no static devices
|
|
|
|
# Configure a device-type option with the by-id path from TEST_HW_DEVICE.
|
|
by_id_path = TEST_HW_DEVICE.links[0]
|
|
install_app_ssh.data["schema"] = {"device": "device"}
|
|
install_app_ssh.persist["options"] = {"device": str(by_id_path)}
|
|
|
|
container.id = 123
|
|
docker._info = replace(docker.info, cgroup="1") # pylint: disable=protected-access
|
|
|
|
# Re-enumerated device: same by-id symlink, different kernel node and minor number.
|
|
# When a USB device is unplugged and plugged back in, the kernel may assign a new
|
|
# device node (ttyACM0 → ttyACM1) with a different minor number.
|
|
reenumerated_device = attr.evolve(
|
|
TEST_HW_DEVICE,
|
|
name="ttyACM1",
|
|
path=Path("/dev/ttyACM1"),
|
|
sysfs=Path(
|
|
"/sys/devices/platform/soc/ffe09000.usb/ff500000.usb/xhci-hcd.0.auto/usb1/1-1/1-1.1/1-1.1:1.0/tty/ttyACM1"
|
|
),
|
|
attributes={"MAJOR": "166", "MINOR": "1"}, # minor number changed from 0 to 1
|
|
)
|
|
|
|
with (
|
|
patch.object(App, "write_options"),
|
|
# HAOS not available: proactive cgroup call is skipped, so add_devices is
|
|
# called exactly once — from the hardware event via the options listener.
|
|
patch.object(OSManager, "available", new=PropertyMock(return_value=False)),
|
|
patch.object(
|
|
type(coresys.host),
|
|
"features",
|
|
new=PropertyMock(return_value=[HostFeature.OS_AGENT]),
|
|
),
|
|
# Mock option_device_paths to ensure it returns the by-id path
|
|
patch.object(
|
|
type(install_app_ssh),
|
|
"option_device_paths",
|
|
new=PropertyMock(return_value={by_id_path}),
|
|
),
|
|
patch.object(
|
|
CGroup, "add_devices_allowed", new_callable=AsyncMock
|
|
) as add_devices,
|
|
):
|
|
await install_app_ssh.start()
|
|
await fire_bus_event(coresys, BusEvent.HARDWARE_NEW_DEVICE, reenumerated_device)
|
|
|
|
# Verify cgroup permission was granted for the re-enumerated device with new minor number
|
|
add_devices.assert_called_once_with(123, "c 166:1 rwm")
|
|
|
|
|
|
@pytest.mark.usefixtures("path_extern", "tmp_supervisor_data")
|
|
async def test_app_options_device_policy_check(
|
|
coresys: CoreSys,
|
|
install_app_ssh: App,
|
|
container: MagicMock,
|
|
docker: DockerAPI,
|
|
):
|
|
"""Test hardware policy blocks device access even for options-based devices.
|
|
|
|
Scenario: an add-on has a device option configured, but the device is blocked
|
|
by hardware policy (e.g., used by system). The hardware event handler must
|
|
respect the policy and not grant cgroup access.
|
|
"""
|
|
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
|
install_app_ssh.data["devices"] = []
|
|
|
|
# Configure a device-type option
|
|
by_id_path = TEST_HW_DEVICE.links[0]
|
|
install_app_ssh.data["schema"] = {"device": "device"}
|
|
install_app_ssh.persist["options"] = {"device": str(by_id_path)}
|
|
|
|
container.id = 123
|
|
docker._info = replace(docker.info, cgroup="1") # pylint: disable=protected-access
|
|
|
|
with (
|
|
patch.object(App, "write_options"),
|
|
patch.object(OSManager, "available", new=PropertyMock(return_value=True)),
|
|
patch.object(
|
|
type(coresys.host),
|
|
"features",
|
|
new=PropertyMock(return_value=[HostFeature.OS_AGENT]),
|
|
),
|
|
# Make the event device match. option_device_paths reads the merged
|
|
# options, which the test's persist override doesn't populate, so without
|
|
# this mock the match guard returns early and the policy check below is
|
|
# never reached (the assertion would then pass for the wrong reason).
|
|
patch.object(
|
|
type(install_app_ssh),
|
|
"option_device_paths",
|
|
new=PropertyMock(return_value={by_id_path}),
|
|
),
|
|
# Mock policy to block this device
|
|
patch.object(
|
|
coresys.hardware.policy,
|
|
"allowed_for_access",
|
|
return_value=False,
|
|
),
|
|
patch.object(
|
|
CGroup, "add_devices_allowed", new_callable=AsyncMock
|
|
) as add_devices,
|
|
):
|
|
await install_app_ssh.start()
|
|
await fire_bus_event(coresys, BusEvent.HARDWARE_NEW_DEVICE, TEST_HW_DEVICE)
|
|
|
|
# Verify cgroup permission was NOT granted due to policy block
|
|
add_devices.assert_not_called()
|