diff --git a/supervisor/api/store.py b/supervisor/api/store.py index 82e14627f..9f689cbac 100644 --- a/supervisor/api/store.py +++ b/supervisor/api/store.py @@ -2,6 +2,7 @@ import asyncio from collections.abc import Awaitable +from pathlib import Path from typing import Any from aiohttp import web @@ -68,6 +69,15 @@ SCHEMA_ADD_REPOSITORY = vol.Schema( ) +def _read_static_file(path: Path) -> Any: + """Read in a static file asset for API output. + + Must be run in executor. + """ + with path.open("r") as asset: + return asset.read() + + class APIStore(CoreSysAttributes): """Handle RESTful API for store functions.""" @@ -233,8 +243,7 @@ class APIStore(CoreSysAttributes): if not addon.with_icon: raise APIError(f"No icon found for add-on {addon.slug}!") - with addon.path_icon.open("rb") as png: - return png.read() + return await self.sys_run_in_executor(_read_static_file, addon.path_icon) @api_process_raw(CONTENT_TYPE_PNG) async def addons_addon_logo(self, request: web.Request) -> bytes: @@ -243,8 +252,7 @@ class APIStore(CoreSysAttributes): if not addon.with_logo: raise APIError(f"No logo found for add-on {addon.slug}!") - with addon.path_logo.open("rb") as png: - return png.read() + return await self.sys_run_in_executor(_read_static_file, addon.path_logo) @api_process_raw(CONTENT_TYPE_TEXT) async def addons_addon_changelog(self, request: web.Request) -> str: @@ -258,8 +266,7 @@ class APIStore(CoreSysAttributes): if not addon.with_changelog: return f"No changelog found for add-on {addon.slug}!" - with addon.path_changelog.open("r") as changelog: - return changelog.read() + return await self.sys_run_in_executor(_read_static_file, addon.path_changelog) @api_process_raw(CONTENT_TYPE_TEXT) async def addons_addon_documentation(self, request: web.Request) -> str: @@ -273,8 +280,9 @@ class APIStore(CoreSysAttributes): if not addon.with_documentation: return f"No documentation found for add-on {addon.slug}!" - with addon.path_documentation.open("r") as documentation: - return documentation.read() + return await self.sys_run_in_executor( + _read_static_file, addon.path_documentation + ) @api_process async def repositories_list(self, request: web.Request) -> list[dict[str, Any]]: diff --git a/supervisor/host/apparmor.py b/supervisor/host/apparmor.py index 0cde72b65..ae5843101 100644 --- a/supervisor/host/apparmor.py +++ b/supervisor/host/apparmor.py @@ -71,7 +71,9 @@ class AppArmorControl(CoreSysAttributes): async def load_profile(self, profile_name: str, profile_file: Path) -> None: """Load/Update a new/exists profile into AppArmor.""" - if not validate_profile(profile_name, profile_file): + if not await self.sys_run_in_executor( + validate_profile, profile_name, profile_file + ): raise HostAppArmorError( f"AppArmor profile '{profile_name}' is not valid", _LOGGER.error ) diff --git a/supervisor/mounts/mount.py b/supervisor/mounts/mount.py index da352a6c6..61b69b416 100644 --- a/supervisor/mounts/mount.py +++ b/supervisor/mounts/mount.py @@ -278,21 +278,25 @@ class Mount(CoreSysAttributes, ABC): """Mount using systemd.""" # If supervisor can see where it will mount, ensure there's an empty folder there if self.local_where: - if not self.local_where.exists(): - _LOGGER.info( - "Creating folder for mount: %s", self.local_where.as_posix() - ) - self.local_where.mkdir(parents=True) - elif not self.local_where.is_dir(): - raise MountInvalidError( - f"Cannot mount {self.name} at {self.local_where.as_posix()} as it is not a directory", - _LOGGER.error, - ) - elif any(self.local_where.iterdir()): - raise MountInvalidError( - f"Cannot mount {self.name} at {self.local_where.as_posix()} because it is not empty", - _LOGGER.error, - ) + + def ensure_empty_folder() -> None: + if not self.local_where.exists(): + _LOGGER.info( + "Creating folder for mount: %s", self.local_where.as_posix() + ) + self.local_where.mkdir(parents=True) + elif not self.local_where.is_dir(): + raise MountInvalidError( + f"Cannot mount {self.name} at {self.local_where.as_posix()} as it is not a directory", + _LOGGER.error, + ) + elif any(self.local_where.iterdir()): + raise MountInvalidError( + f"Cannot mount {self.name} at {self.local_where.as_posix()} because it is not empty", + _LOGGER.error, + ) + + await self.sys_run_in_executor(ensure_empty_folder) try: options = ( @@ -488,17 +492,23 @@ class CIFSMount(NetworkMount): async def mount(self) -> None: """Mount using systemd.""" if self.username and self.password: - if not self.path_credentials.exists(): - self.path_credentials.touch(mode=0o600) - with self.path_credentials.open(mode="w") as cred_file: - cred_file.write(f"username={self.username}\npassword={self.password}") + def write_credentials() -> None: + if not self.path_credentials.exists(): + self.path_credentials.touch(mode=0o600) + + with self.path_credentials.open(mode="w") as cred_file: + cred_file.write( + f"username={self.username}\npassword={self.password}" + ) + + await self.sys_run_in_executor(write_credentials) await super().mount() async def unmount(self) -> None: """Unmount using systemd.""" - self.path_credentials.unlink(missing_ok=True) + await self.sys_run_in_executor(self.path_credentials.unlink, missing_ok=True) await super().unmount() diff --git a/supervisor/os/manager.py b/supervisor/os/manager.py index ee37595fd..e894c1fa2 100644 --- a/supervisor/os/manager.py +++ b/supervisor/os/manager.py @@ -217,12 +217,15 @@ class OSManager(CoreSysAttributes): ) # Download RAUCB file - with raucb.open("wb") as ota_file: + ota_file = await self.sys_run_in_executor(raucb.open, "wb") + try: while True: chunk = await request.content.read(1_048_576) if not chunk: break - ota_file.write(chunk) + await self.sys_run_in_executor(ota_file.write, chunk) + finally: + await self.sys_run_in_executor(ota_file.close) _LOGGER.info("Completed download of OTA update file %s", raucb) diff --git a/supervisor/store/data.py b/supervisor/store/data.py index 47b5f4725..c7fa3fbc1 100644 --- a/supervisor/store/data.py +++ b/supervisor/store/data.py @@ -74,7 +74,10 @@ def _read_addon_translations(addon_path: Path) -> dict: def _read_git_repository(path: Path) -> ProcessedRepository | None: - """Process a custom repository folder.""" + """Process a custom repository folder. + + Must be run in executor. + """ slug = extract_hash_from_path(path) # exists repository json diff --git a/supervisor/store/repository.py b/supervisor/store/repository.py index 3ea4713ec..519312691 100644 --- a/supervisor/store/repository.py +++ b/supervisor/store/repository.py @@ -74,7 +74,10 @@ class Repository(CoreSysAttributes): return self.data.get(ATTR_MAINTAINER, UNKNOWN) def validate(self) -> bool: - """Check if store is valid.""" + """Check if store is valid. + + Must be run in executor. + """ if self.type != StoreType.GIT: return True @@ -104,7 +107,7 @@ class Repository(CoreSysAttributes): async def update(self) -> bool: """Update add-on repository.""" - if not self.validate(): + if not await self.sys_run_in_executor(self.validate): return False return self.type == StoreType.LOCAL or await self.git.pull() diff --git a/supervisor/utils/apparmor.py b/supervisor/utils/apparmor.py index 50de8fc9b..8aea0049d 100644 --- a/supervisor/utils/apparmor.py +++ b/supervisor/utils/apparmor.py @@ -12,7 +12,10 @@ RE_PROFILE = re.compile(r"^profile ([^ ]+).*$") def get_profile_name(profile_file: Path) -> str: - """Read the profile name from file.""" + """Read the profile name from file. + + Must be run in executor. + """ profiles = set() try: @@ -42,14 +45,20 @@ def get_profile_name(profile_file: Path) -> str: def validate_profile(profile_name: str, profile_file: Path) -> bool: - """Check if profile from file is valid with profile name.""" + """Check if profile from file is valid with profile name. + + Must be run in executor. + """ if profile_name == get_profile_name(profile_file): return True return False def adjust_profile(profile_name: str, profile_file: Path, profile_new: Path) -> None: - """Fix the profile name.""" + """Fix the profile name. + + Must be run in executor. + """ org_profile = get_profile_name(profile_file) profile_data = [] diff --git a/supervisor/utils/common.py b/supervisor/utils/common.py index 1cd244ebd..1e8797646 100644 --- a/supervisor/utils/common.py +++ b/supervisor/utils/common.py @@ -19,7 +19,10 @@ _DEFAULT: dict[str, Any] = {} def find_one_filetype(path: Path, filename: str, filetypes: list[str]) -> Path: - """Find first file matching filetypes.""" + """Find first file matching filetypes. + + Must be run in executor. + """ for file in path.glob(f"**/{filename}.*"): if file.suffix in filetypes: return file @@ -27,7 +30,10 @@ def find_one_filetype(path: Path, filename: str, filetypes: list[str]) -> Path: def read_json_or_yaml_file(path: Path) -> dict: - """Read JSON or YAML file.""" + """Read JSON or YAML file. + + Must be run in executor. + """ if path.suffix == ".json": return read_json_file(path) @@ -38,7 +44,10 @@ def read_json_or_yaml_file(path: Path) -> dict: def write_json_or_yaml_file(path: Path, data: dict) -> None: - """Write JSON or YAML file.""" + """Write JSON or YAML file. + + Must be run in executor. + """ if path.suffix == ".json": return write_json_file(path, data) diff --git a/supervisor/utils/yaml.py b/supervisor/utils/yaml.py index 1448901db..d02a0a988 100644 --- a/supervisor/utils/yaml.py +++ b/supervisor/utils/yaml.py @@ -17,7 +17,10 @@ _LOGGER: logging.Logger = logging.getLogger(__name__) def read_yaml_file(path: Path) -> dict: - """Read YAML file from path.""" + """Read YAML file from path. + + Must be run in executor. + """ try: with open(path, encoding="utf-8") as yaml_file: return load(yaml_file, Loader=SafeLoader) or {} @@ -29,7 +32,10 @@ def read_yaml_file(path: Path) -> dict: def write_yaml_file(path: Path, data: dict) -> None: - """Write a YAML file.""" + """Write a YAML file. + + Must be run in executor. + """ try: with atomic_write(path, overwrite=True) as fp: dump(data, fp, Dumper=Dumper) diff --git a/tests/mounts/test_manager.py b/tests/mounts/test_manager.py index 7dc8f84c7..6fae3d597 100644 --- a/tests/mounts/test_manager.py +++ b/tests/mounts/test_manager.py @@ -3,6 +3,7 @@ import json import os from pathlib import Path +from unittest.util import unorderable_list_difference from dbus_fast import DBusError, ErrorType, Variant from dbus_fast.aio.message_bus import MessageBus @@ -111,40 +112,46 @@ async def test_load( assert media_test.local_where.is_dir() assert (coresys.config.path_media / "media_test").is_dir() - assert systemd_service.StartTransientUnit.calls == [ - ( - "mnt-data-supervisor-mounts-backup_test.mount", - "fail", - [ - ["Options", Variant("s", "noserverino,guest")], - ["Type", Variant("s", "cifs")], - ["Description", Variant("s", "Supervisor cifs mount: backup_test")], - ["What", Variant("s", "//backup.local/backups")], - ], - [], - ), - ( - "mnt-data-supervisor-mounts-media_test.mount", - "fail", - [ - ["Options", Variant("s", "soft,timeo=200")], - ["Type", Variant("s", "nfs")], - ["Description", Variant("s", "Supervisor nfs mount: media_test")], - ["What", Variant("s", "media.local:/media")], - ], - [], - ), - ( - "mnt-data-supervisor-media-media_test.mount", - "fail", - [ - ["Options", Variant("s", "bind")], - ["Description", Variant("s", "Supervisor bind mount: bind_media_test")], - ["What", Variant("s", "/mnt/data/supervisor/mounts/media_test")], - ], - [], - ), - ] + assert unorderable_list_difference( + systemd_service.StartTransientUnit.calls, + [ + ( + "mnt-data-supervisor-mounts-backup_test.mount", + "fail", + [ + ["Options", Variant("s", "noserverino,guest")], + ["Type", Variant("s", "cifs")], + ["Description", Variant("s", "Supervisor cifs mount: backup_test")], + ["What", Variant("s", "//backup.local/backups")], + ], + [], + ), + ( + "mnt-data-supervisor-mounts-media_test.mount", + "fail", + [ + ["Options", Variant("s", "soft,timeo=200")], + ["Type", Variant("s", "nfs")], + ["Description", Variant("s", "Supervisor nfs mount: media_test")], + ["What", Variant("s", "media.local:/media")], + ], + [], + ), + ( + "mnt-data-supervisor-media-media_test.mount", + "fail", + [ + ["Options", Variant("s", "bind")], + [ + "Description", + Variant("s", "Supervisor bind mount: bind_media_test"), + ], + ["What", Variant("s", "/mnt/data/supervisor/mounts/media_test")], + ], + [], + ), + ], + ) == ([], []) async def test_load_share_mount(