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_NAME: addon.name,
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 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)
# Perform a restore

View File

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

View File

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

View File

@ -54,10 +54,16 @@ class AppArmorControl(CoreSysAttributes):
async def load(self) -> None:
"""Load available profiles."""
for content in self.sys_config.path_apparmor.iterdir():
if not content.is_file():
continue
self._profiles.add(content.name)
def find_profiles() -> set[str]:
profiles: set[str] = set()
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)

View File

@ -292,9 +292,12 @@ class MountManager(FileConfiguration, CoreSysAttributes):
where.as_posix(),
)
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)
self._bound_mounts[mount.name] = bound_mount = BoundMount(

View File

@ -30,9 +30,7 @@ class CheckCoreSecurity(CheckBase):
# Security issue < 2021.1.5 & Custom components
try:
if self.sys_homeassistant.version < AwesomeVersion("2021.1.5"):
if Path(
self.sys_config.path_homeassistant, "custom_components"
).exists():
if await self.sys_run_in_executor(self._custom_components_exists):
self.sys_resolution.create_issue(
IssueType.SECURITY,
ContextType.CORE,
@ -49,9 +47,14 @@ class CheckCoreSecurity(CheckBase):
return False
except AwesomeVersionException:
return True
if not Path(self.sys_config.path_homeassistant, "custom_components").exists():
return False
return True
return await self.sys_run_in_executor(self._custom_components_exists)
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
def issue(self) -> IssueType:

View File

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

View File

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

View File

@ -1,5 +1,6 @@
"""Test dbus interface."""
import asyncio
from unittest.mock import patch
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.properties_interface = "test.no.properties.interface"
async def mock_introspect(*args, **kwargs):
def mock_introspect(*args, **kwargs):
"""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),

View File

@ -113,6 +113,10 @@ async def test_get_checks(coresys: CoreSys):
async def test_dynamic_check_loader(coresys: CoreSys):
"""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

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
)
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 coresys.resolution.suggestions[-1].type == SuggestionType.EXECUTE_REMOVE
@ -176,11 +178,15 @@ async def test_preinstall_valid_repository(
"""Test add core repository valid."""
with patch("supervisor.store.repository.Repository.load", return_value=None):
await store_manager.update_repositories(BUILTIN_REPOSITORIES)
assert store_manager.get("core").validate()
assert store_manager.get("local").validate()
assert store_manager.get("a0d7b954").validate()
assert store_manager.get("5c53de3b").validate()
assert store_manager.get("d5369777").validate()
def validate():
assert store_manager.get("core").validate()
assert store_manager.get("local").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])

View File

@ -146,7 +146,9 @@ async def test_update_unavailable_addon(
):
"""Test updating addon when new version not available for system."""
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"),
**config,
)
@ -201,7 +203,9 @@ async def test_install_unavailable_addon(
):
"""Test updating addon when new version not available for system."""
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"),
**config,
)

View File

@ -80,9 +80,11 @@ async def test_ingress_save_data(coresys: CoreSys, tmp_supervisor_data: Path):
)
await ingress.save_data()
assert config_file.exists()
data = read_json_file(config_file)
assert data == {
def get_config():
assert config_file.exists()
return read_json_file(config_file)
assert await coresys.run_in_executor(get_config) == {
"session": {session: ANY},
"session_data": {
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."""
profile_out = tmp_path / "apparmor_out.txt"
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
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."""
profile_out = tmp_path / "apparmor_out.txt"
adjust_profile("test", get_fixture_path("apparmor_valid_mediate.txt"), profile_out)

View File

@ -1,5 +1,6 @@
"""Test dbus utility."""
import asyncio
from unittest.mock import AsyncMock, Mock, patch
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):
"""Test introspection missing properties interface."""
async def mock_introspect(*args, **kwargs):
def mock_introspect(*args, **kwargs):
"""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):
service = await DBus.connect(

View File

@ -2,10 +2,13 @@
from unittest.mock import patch
import pytest
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."""
with (
patch("supervisor.bootstrap.initialize_system"),