Finish out effort of adding and enabling blockbuster in tests (#5735)

* Finish out effort of adding and enabling blockbuster

* Skip getting addon file size until securetar fixed

* Fix test for devcontainer and blocking I/O

* Fix docker fixture and load_config to post_init
This commit is contained in:
Mike Degatano 2025-03-07 07:29:24 -05:00 committed by GitHub
parent 23e03a95f4
commit e1c9c8b786
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 139 additions and 70 deletions

View File

@ -601,7 +601,9 @@ class Backup(JobGroup):
ATTR_SLUG: addon.slug, ATTR_SLUG: addon.slug,
ATTR_NAME: addon.name, ATTR_NAME: addon.name,
ATTR_VERSION: addon.version, ATTR_VERSION: addon.version,
ATTR_SIZE: addon_file.size, # Bug - addon_file.size used to give us this information
# It always returns 0 in current securetar. Skipping until fixed
ATTR_SIZE: 0,
} }
) )
@ -640,7 +642,7 @@ class Backup(JobGroup):
) )
# If exists inside backup # If exists inside backup
if not addon_file.path.exists(): if not await self.sys_run_in_executor(addon_file.path.exists):
raise BackupError(f"Can't find backup {addon_slug}", _LOGGER.error) raise BackupError(f"Can't find backup {addon_slug}", _LOGGER.error)
# Perform a restore # Perform a restore

View File

@ -55,7 +55,7 @@ async def initialize_coresys() -> CoreSys:
coresys = await CoreSys().load_config() coresys = await CoreSys().load_config()
# Initialize core objects # Initialize core objects
coresys.docker = await DockerAPI(coresys).load_config() coresys.docker = await DockerAPI(coresys).post_init()
coresys.resolution = await ResolutionManager(coresys).load_config() coresys.resolution = await ResolutionManager(coresys).load_config()
await coresys.resolution.load_modules() await coresys.resolution.load_modules()
coresys.jobs = await JobManager(coresys).load_config() coresys.jobs = await JobManager(coresys).load_config()

View File

@ -1,6 +1,8 @@
"""Manager for Supervisor Docker.""" """Manager for Supervisor Docker."""
import asyncio
from contextlib import suppress from contextlib import suppress
from functools import partial
from ipaddress import IPv4Address from ipaddress import IPv4Address
import logging import logging
import os import os
@ -105,19 +107,42 @@ class DockerAPI:
def __init__(self, coresys: CoreSys): def __init__(self, coresys: CoreSys):
"""Initialize Docker base wrapper.""" """Initialize Docker base wrapper."""
self.docker: DockerClient = DockerClient( self._docker: DockerClient | None = None
base_url=f"unix:/{str(SOCKET_DOCKER)}", version="auto", timeout=900 self._network: DockerNetwork | None = None
) self._info: DockerInfo | None = None
self.network: DockerNetwork = DockerNetwork(self.docker)
self._info: DockerInfo = DockerInfo.new(self.docker.info())
self.config: DockerConfig = DockerConfig() self.config: DockerConfig = DockerConfig()
self._monitor: DockerMonitor = DockerMonitor(coresys) self._monitor: DockerMonitor = DockerMonitor(coresys)
async def load_config(self) -> Self: async def post_init(self) -> Self:
"""Load config in executor.""" """Post init actions that must be done in event loop."""
self._docker = await asyncio.get_running_loop().run_in_executor(
None,
partial(
DockerClient,
base_url=f"unix:/{str(SOCKET_DOCKER)}",
version="auto",
timeout=900,
),
)
self._network = DockerNetwork(self._docker)
self._info = DockerInfo.new(self.docker.info())
await self.config.read_data() await self.config.read_data()
return self return self
@property
def docker(self) -> DockerClient:
"""Get docker API client."""
if not self._docker:
raise RuntimeError("Docker API Client not initialized!")
return self._docker
@property
def network(self) -> DockerNetwork:
"""Get Docker network."""
if not self._network:
raise RuntimeError("Docker Network not initialized!")
return self._network
@property @property
def images(self) -> ImageCollection: def images(self) -> ImageCollection:
"""Return API images.""" """Return API images."""
@ -136,6 +161,8 @@ class DockerAPI:
@property @property
def info(self) -> DockerInfo: def info(self) -> DockerInfo:
"""Return local docker info.""" """Return local docker info."""
if not self._info:
raise RuntimeError("Docker Info not initialized!")
return self._info return self._info
@property @property

View File

@ -54,10 +54,16 @@ class AppArmorControl(CoreSysAttributes):
async def load(self) -> None: async def load(self) -> None:
"""Load available profiles.""" """Load available profiles."""
for content in self.sys_config.path_apparmor.iterdir():
if not content.is_file(): def find_profiles() -> set[str]:
continue profiles: set[str] = set()
self._profiles.add(content.name) for content in self.sys_config.path_apparmor.iterdir():
if not content.is_file():
continue
profiles.add(content.name)
return profiles
self._profiles = await self.sys_run_in_executor(find_profiles)
_LOGGER.info("Loading AppArmor Profiles: %s", self._profiles) _LOGGER.info("Loading AppArmor Profiles: %s", self._profiles)

View File

@ -292,9 +292,12 @@ class MountManager(FileConfiguration, CoreSysAttributes):
where.as_posix(), where.as_posix(),
) )
path = self.sys_config.path_emergency / mount.name path = self.sys_config.path_emergency / mount.name
if not path.exists():
path.mkdir(mode=0o444)
def emergency_mkdir():
if not path.exists():
path.mkdir(mode=0o444)
await self.sys_run_in_executor(emergency_mkdir)
path = self.sys_config.local_to_extern_path(path) path = self.sys_config.local_to_extern_path(path)
self._bound_mounts[mount.name] = bound_mount = BoundMount( self._bound_mounts[mount.name] = bound_mount = BoundMount(

View File

@ -30,9 +30,7 @@ class CheckCoreSecurity(CheckBase):
# Security issue < 2021.1.5 & Custom components # Security issue < 2021.1.5 & Custom components
try: try:
if self.sys_homeassistant.version < AwesomeVersion("2021.1.5"): if self.sys_homeassistant.version < AwesomeVersion("2021.1.5"):
if Path( if await self.sys_run_in_executor(self._custom_components_exists):
self.sys_config.path_homeassistant, "custom_components"
).exists():
self.sys_resolution.create_issue( self.sys_resolution.create_issue(
IssueType.SECURITY, IssueType.SECURITY,
ContextType.CORE, ContextType.CORE,
@ -49,9 +47,14 @@ class CheckCoreSecurity(CheckBase):
return False return False
except AwesomeVersionException: except AwesomeVersionException:
return True return True
if not Path(self.sys_config.path_homeassistant, "custom_components").exists(): return await self.sys_run_in_executor(self._custom_components_exists)
return False
return True def _custom_components_exists(self) -> bool:
"""Return true if custom components folder exists.
Must be run in executor.
"""
return Path(self.sys_config.path_homeassistant, "custom_components").exists()
@property @property
def issue(self) -> IssueType: def issue(self) -> IssueType:

View File

@ -1679,15 +1679,17 @@ async def test_skip_homeassistant_database(
coresys.homeassistant.backups_exclude_database = exclude_db_setting coresys.homeassistant.backups_exclude_database = exclude_db_setting
test_file = coresys.config.path_homeassistant / "configuration.yaml" test_file = coresys.config.path_homeassistant / "configuration.yaml"
(test_db := coresys.config.path_homeassistant / "home-assistant_v2.db").touch() test_db = coresys.config.path_homeassistant / "home-assistant_v2.db"
( test_db_wal = coresys.config.path_homeassistant / "home-assistant_v2.db-wal"
test_db_wal := coresys.config.path_homeassistant / "home-assistant_v2.db-wal" test_db_shm = coresys.config.path_homeassistant / "home-assistant_v2.db-shm"
).touch()
(
test_db_shm := coresys.config.path_homeassistant / "home-assistant_v2.db-shm"
).touch()
write_json_file(test_file, {"default_config": {}}) def setup_1():
test_db.touch()
test_db_wal.touch()
test_db_shm.touch()
write_json_file(test_file, {"default_config": {}})
await coresys.run_in_executor(setup_1)
kwargs = {} if exclude_db_setting else {"homeassistant_exclude_database": True} kwargs = {} if exclude_db_setting else {"homeassistant_exclude_database": True}
if partial_backup: if partial_backup:
@ -1697,9 +1699,12 @@ async def test_skip_homeassistant_database(
else: else:
backup: Backup = await coresys.backups.do_backup_full(**kwargs) backup: Backup = await coresys.backups.do_backup_full(**kwargs)
test_file.unlink() def setup_2():
write_json_file(test_db, {"hello": "world"}) test_file.unlink()
write_json_file(test_db_wal, {"hello": "world"}) write_json_file(test_db, {"hello": "world"})
write_json_file(test_db_wal, {"hello": "world"})
await coresys.run_in_executor(setup_2)
with ( with (
patch.object(HomeAssistantCore, "update"), patch.object(HomeAssistantCore, "update"),
@ -1707,10 +1712,13 @@ async def test_skip_homeassistant_database(
): ):
await coresys.backups.do_restore_partial(backup, homeassistant=True) await coresys.backups.do_restore_partial(backup, homeassistant=True)
assert read_json_file(test_file) == {"default_config": {}} def test_assertions():
assert read_json_file(test_db) == {"hello": "world"} assert read_json_file(test_file) == {"default_config": {}}
assert read_json_file(test_db_wal) == {"hello": "world"} assert read_json_file(test_db) == {"hello": "world"}
assert not test_db_shm.exists() assert read_json_file(test_db_wal) == {"hello": "world"}
assert not test_db_shm.exists()
await coresys.run_in_executor(test_assertions)
@pytest.mark.usefixtures("tmp_supervisor_data", "path_extern") @pytest.mark.usefixtures("tmp_supervisor_data", "path_extern")

View File

@ -64,21 +64,16 @@ from .dbus_service_mocks.network_manager import NetworkManager as NetworkManager
# pylint: disable=redefined-outer-name, protected-access # pylint: disable=redefined-outer-name, protected-access
# This commented out code is left in intentionally @pytest.fixture(autouse=True)
# Intent is to enable this for all tests at all times as an autouse fixture def blockbuster() -> BlockBuster:
# Findings from PR were growing too big so disabling temporarily to create a checkpoint
# @pytest.fixture(autouse=True)
def blockbuster(request: pytest.FixtureRequest) -> BlockBuster:
"""Raise for blocking I/O in event loop.""" """Raise for blocking I/O in event loop."""
# Excluded modules doesn't seem to stop test code from raising for blocking I/O # Only scanning supervisor code for now as that's our primary interest
# Defaulting to only scanning supervisor core code seems like the best we can do easily # This will still raise for tests that call utilities in supervisor code that block
# Added a parameter so we could potentially go module by module in test and eliminate blocking I/O # But it will ignore calls to libraries and such that do blocking I/O directly from tests
# Then we could tell it to scan everything by default. That will require more follow-up work # Removing that would be nice but a todo for the future
# pylint: disable-next=contextmanager-generator-missing-cleanup # pylint: disable-next=contextmanager-generator-missing-cleanup
with blockbuster_ctx( with blockbuster_ctx(scanned_modules=["supervisor"]) as bb:
scanned_modules=getattr(request, "param", ["supervisor"])
) as bb:
yield bb yield bb
@ -118,7 +113,7 @@ async def docker() -> DockerAPI:
), ),
patch("supervisor.docker.manager.DockerAPI.unload"), patch("supervisor.docker.manager.DockerAPI.unload"),
): ):
docker_obj = DockerAPI(MagicMock()) docker_obj = await DockerAPI(MagicMock()).post_init()
docker_obj.config._data = {"registries": {}} docker_obj.config._data = {"registries": {}}
with patch("supervisor.docker.monitor.DockerMonitor.load"): with patch("supervisor.docker.monitor.DockerMonitor.load"):
await docker_obj.load() await docker_obj.load()

View File

@ -1,5 +1,6 @@
"""Test dbus interface.""" """Test dbus interface."""
import asyncio
from unittest.mock import patch from unittest.mock import patch
from dbus_fast.aio.message_bus import MessageBus from dbus_fast.aio.message_bus import MessageBus
@ -145,9 +146,11 @@ async def test_proxy_missing_properties_interface(dbus_session_bus: MessageBus):
proxy.object_path = DBUS_OBJECT_BASE proxy.object_path = DBUS_OBJECT_BASE
proxy.properties_interface = "test.no.properties.interface" proxy.properties_interface = "test.no.properties.interface"
async def mock_introspect(*args, **kwargs): def mock_introspect(*args, **kwargs):
"""Return introspection without properties.""" """Return introspection without properties."""
return load_fixture("test_no_properties_interface.xml") return asyncio.get_running_loop().run_in_executor(
None, load_fixture, "test_no_properties_interface.xml"
)
with ( with (
patch.object(MessageBus, "introspect", new=mock_introspect), patch.object(MessageBus, "introspect", new=mock_introspect),

View File

@ -113,6 +113,10 @@ async def test_get_checks(coresys: CoreSys):
async def test_dynamic_check_loader(coresys: CoreSys): async def test_dynamic_check_loader(coresys: CoreSys):
"""Test dynamic check loader, this ensures that all checks have defined a setup function.""" """Test dynamic check loader, this ensures that all checks have defined a setup function."""
coresys.resolution.check.load_modules()
for check in await coresys.run_in_executor(get_valid_modules, "checks"): def load_modules():
coresys.resolution.check.load_modules()
return get_valid_modules("checks")
for check in await coresys.run_in_executor(load_modules):
assert check in coresys.resolution.check._checks assert check in coresys.resolution.check._checks

View File

@ -58,7 +58,9 @@ async def test_add_invalid_repository(coresys: CoreSys, store_manager: StoreMana
current + ["http://example.com"], add_with_errors=True current + ["http://example.com"], add_with_errors=True
) )
assert not store_manager.get_from_url("http://example.com").validate() assert not await coresys.run_in_executor(
store_manager.get_from_url("http://example.com").validate
)
assert "http://example.com" in coresys.store.repository_urls assert "http://example.com" in coresys.store.repository_urls
assert coresys.resolution.suggestions[-1].type == SuggestionType.EXECUTE_REMOVE assert coresys.resolution.suggestions[-1].type == SuggestionType.EXECUTE_REMOVE
@ -176,11 +178,15 @@ async def test_preinstall_valid_repository(
"""Test add core repository valid.""" """Test add core repository valid."""
with patch("supervisor.store.repository.Repository.load", return_value=None): with patch("supervisor.store.repository.Repository.load", return_value=None):
await store_manager.update_repositories(BUILTIN_REPOSITORIES) await store_manager.update_repositories(BUILTIN_REPOSITORIES)
assert store_manager.get("core").validate()
assert store_manager.get("local").validate() def validate():
assert store_manager.get("a0d7b954").validate() assert store_manager.get("core").validate()
assert store_manager.get("5c53de3b").validate() assert store_manager.get("local").validate()
assert store_manager.get("d5369777").validate() assert store_manager.get("a0d7b954").validate()
assert store_manager.get("5c53de3b").validate()
assert store_manager.get("d5369777").validate()
await coresys.run_in_executor(validate)
@pytest.mark.parametrize("use_update", [True, False]) @pytest.mark.parametrize("use_update", [True, False])

View File

@ -146,7 +146,9 @@ async def test_update_unavailable_addon(
): ):
"""Test updating addon when new version not available for system.""" """Test updating addon when new version not available for system."""
addon_config = dict( addon_config = dict(
load_yaml_fixture("addons/local/ssh/config.yaml"), await coresys.run_in_executor(
load_yaml_fixture, "addons/local/ssh/config.yaml"
),
version=AwesomeVersion("10.0.0"), version=AwesomeVersion("10.0.0"),
**config, **config,
) )
@ -201,7 +203,9 @@ async def test_install_unavailable_addon(
): ):
"""Test updating addon when new version not available for system.""" """Test updating addon when new version not available for system."""
addon_config = dict( addon_config = dict(
load_yaml_fixture("addons/local/ssh/config.yaml"), await coresys.run_in_executor(
load_yaml_fixture, "addons/local/ssh/config.yaml"
),
version=AwesomeVersion("10.0.0"), version=AwesomeVersion("10.0.0"),
**config, **config,
) )

View File

@ -80,9 +80,11 @@ async def test_ingress_save_data(coresys: CoreSys, tmp_supervisor_data: Path):
) )
await ingress.save_data() await ingress.save_data()
assert config_file.exists() def get_config():
data = read_json_file(config_file) assert config_file.exists()
assert data == { return read_json_file(config_file)
assert await coresys.run_in_executor(get_config) == {
"session": {session: ANY}, "session": {session: ANY},
"session_data": { "session_data": {
session: {"user": {"id": "123", "displayname": "Test", "username": "test"}} session: {"user": {"id": "123", "displayname": "Test", "username": "test"}}

View File

@ -55,7 +55,7 @@ async def test_apparmor_multiple_profiles(caplog: pytest.LogCaptureFixture):
) )
async def test_apparmor_profile_adjust(tmp_path: Path): def test_apparmor_profile_adjust(tmp_path: Path):
"""Test apparmor profile adjust.""" """Test apparmor profile adjust."""
profile_out = tmp_path / "apparmor_out.txt" profile_out = tmp_path / "apparmor_out.txt"
adjust_profile("test", get_fixture_path("apparmor_valid.txt"), profile_out) adjust_profile("test", get_fixture_path("apparmor_valid.txt"), profile_out)
@ -63,7 +63,7 @@ async def test_apparmor_profile_adjust(tmp_path: Path):
assert profile_out.read_text(encoding="utf-8") == TEST_PROFILE assert profile_out.read_text(encoding="utf-8") == TEST_PROFILE
async def test_apparmor_profile_adjust_mediate(tmp_path: Path): def test_apparmor_profile_adjust_mediate(tmp_path: Path):
"""Test apparmor profile adjust when name matches a flag.""" """Test apparmor profile adjust when name matches a flag."""
profile_out = tmp_path / "apparmor_out.txt" profile_out = tmp_path / "apparmor_out.txt"
adjust_profile("test", get_fixture_path("apparmor_valid_mediate.txt"), profile_out) adjust_profile("test", get_fixture_path("apparmor_valid_mediate.txt"), profile_out)

View File

@ -1,5 +1,6 @@
"""Test dbus utility.""" """Test dbus utility."""
import asyncio
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, Mock, patch
from dbus_fast import ErrorType from dbus_fast import ErrorType
@ -48,9 +49,11 @@ async def fixture_test_service(dbus_session_bus: MessageBus) -> TestInterface:
async def test_missing_properties_interface(dbus_session_bus: MessageBus): async def test_missing_properties_interface(dbus_session_bus: MessageBus):
"""Test introspection missing properties interface.""" """Test introspection missing properties interface."""
async def mock_introspect(*args, **kwargs): def mock_introspect(*args, **kwargs):
"""Return introspection without properties.""" """Return introspection without properties."""
return load_fixture("test_no_properties_interface.xml") return asyncio.get_running_loop().run_in_executor(
None, load_fixture, "test_no_properties_interface.xml"
)
with patch.object(MessageBus, "introspect", new=mock_introspect): with patch.object(MessageBus, "introspect", new=mock_introspect):
service = await DBus.connect( service = await DBus.connect(

View File

@ -2,10 +2,13 @@
from unittest.mock import patch from unittest.mock import patch
import pytest
from supervisor.bootstrap import initialize_coresys from supervisor.bootstrap import initialize_coresys
async def test_sentry_disabled_by_default(supervisor_name): @pytest.mark.usefixtures("supervisor_name", "docker")
async def test_sentry_disabled_by_default():
"""Test diagnostics off by default.""" """Test diagnostics off by default."""
with ( with (
patch("supervisor.bootstrap.initialize_system"), patch("supervisor.bootstrap.initialize_system"),