Eliminate possible addon data race condition during update (#4619)

* Eliminate possible addon data race condition during update

* Fix pylint error

* Use Self type instead of quotes
This commit is contained in:
Mike Degatano 2023-10-11 12:22:04 -04:00 committed by GitHub
parent 1827ecda65
commit 1376a38de5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 67 additions and 5 deletions

View File

@ -290,6 +290,8 @@ class AddonManager(CoreSysAttributes):
# Update instance # Update instance
last_state: AddonState = addon.state last_state: AddonState = addon.state
old_image = addon.image old_image = addon.image
# Cache data to prevent races with other updates to global
store = store.clone()
try: try:
await addon.instance.update(store.version, store.image) await addon.instance.update(store.version, store.image)
except DockerError as err: except DockerError as err:
@ -300,7 +302,9 @@ class AddonManager(CoreSysAttributes):
# Cleanup # Cleanup
with suppress(DockerError): with suppress(DockerError):
await addon.instance.cleanup(old_image=old_image) await addon.instance.cleanup(
old_image=old_image, image=store.image, version=store.version
)
# Setup/Fix AppArmor profile # Setup/Fix AppArmor profile
await addon.install_apparmor() await addon.install_apparmor()

View File

@ -449,12 +449,17 @@ class DockerInterface(JobGroup):
return b"" return b""
@Job(name="docker_interface_cleanup", limit=JobExecutionLimit.GROUP_WAIT) @Job(name="docker_interface_cleanup", limit=JobExecutionLimit.GROUP_WAIT)
def cleanup(self, old_image: str | None = None) -> Awaitable[None]: def cleanup(
self,
old_image: str | None = None,
image: str | None = None,
version: AwesomeVersion | None = None,
) -> Awaitable[None]:
"""Check if old version exists and cleanup.""" """Check if old version exists and cleanup."""
return self.sys_run_in_executor( return self.sys_run_in_executor(
self.sys_docker.cleanup_old_images, self.sys_docker.cleanup_old_images,
self.image, image or self.image,
self.version, version or self.version,
{old_image} if old_image else None, {old_image} if old_image else None,
) )

View File

@ -1,7 +1,11 @@
"""Init file for Supervisor add-ons.""" """Init file for Supervisor add-ons."""
from copy import deepcopy
import logging import logging
from typing import Self
from ..addons.model import AddonModel, Data from ..addons.model import AddonModel, Data
from ..coresys import CoreSys
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@ -9,6 +13,11 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
class AddonStore(AddonModel): class AddonStore(AddonModel):
"""Hold data for add-on inside Supervisor.""" """Hold data for add-on inside Supervisor."""
def __init__(self, coresys: CoreSys, slug: str, data: Data | None = None):
"""Initialize object."""
super().__init__(coresys, slug)
self._data: Data | None = data
def __repr__(self) -> str: def __repr__(self) -> str:
"""Return internal representation.""" """Return internal representation."""
return f"<Store: {self.slug}>" return f"<Store: {self.slug}>"
@ -16,7 +25,7 @@ class AddonStore(AddonModel):
@property @property
def data(self) -> Data: def data(self) -> Data:
"""Return add-on data/config.""" """Return add-on data/config."""
return self.sys_store.data.addons[self.slug] return self._data or self.sys_store.data.addons[self.slug]
@property @property
def is_installed(self) -> bool: def is_installed(self) -> bool:
@ -27,3 +36,7 @@ class AddonStore(AddonModel):
def is_detached(self) -> bool: def is_detached(self) -> bool:
"""Return True if add-on is detached.""" """Return True if add-on is detached."""
return False return False
def clone(self) -> Self:
"""Return a copy that includes data and does not use global store data."""
return type(self)(self.coresys, self.slug, deepcopy(self.data))

View File

@ -15,6 +15,7 @@ from supervisor.coresys import CoreSys
from supervisor.docker.addon import DockerAddon from supervisor.docker.addon import DockerAddon
from supervisor.docker.const import ContainerState from supervisor.docker.const import ContainerState
from supervisor.docker.interface import DockerInterface from supervisor.docker.interface import DockerInterface
from supervisor.docker.manager import DockerAPI
from supervisor.docker.monitor import DockerContainerStateEvent from supervisor.docker.monitor import DockerContainerStateEvent
from supervisor.exceptions import ( from supervisor.exceptions import (
AddonConfigurationError, AddonConfigurationError,
@ -23,6 +24,7 @@ from supervisor.exceptions import (
DockerNotFound, DockerNotFound,
) )
from supervisor.plugins.dns import PluginDns from supervisor.plugins.dns import PluginDns
from supervisor.store.repository import Repository
from supervisor.utils import check_exception_chain from supervisor.utils import check_exception_chain
from supervisor.utils.common import write_json_file from supervisor.utils.common import write_json_file
@ -364,3 +366,41 @@ async def test_repository_file_error(
write_json_file(repo_file, {"invalid": "bad"}) write_json_file(repo_file, {"invalid": "bad"})
await coresys.store.data.update() await coresys.store.data.update()
assert f"Repository parse error {repo_dir.as_posix()}" in caplog.text assert f"Repository parse error {repo_dir.as_posix()}" in caplog.text
async def test_store_data_changes_during_update(
coresys: CoreSys, install_addon_ssh: Addon
):
"""Test store data changing for an addon during an update does not cause errors."""
event = asyncio.Event()
coresys.store.data.addons["local_ssh"]["image"] = "test_image"
coresys.store.data.addons["local_ssh"]["version"] = AwesomeVersion("1.1.1")
async def simulate_update():
async def mock_update(_, version, image, *args, **kwargs):
assert version == AwesomeVersion("1.1.1")
assert image == "test_image"
await event.wait()
with patch.object(DockerAddon, "update", new=mock_update), patch.object(
DockerAPI, "cleanup_old_images"
) as cleanup:
await coresys.addons.update("local_ssh")
cleanup.assert_called_once_with(
"test_image", AwesomeVersion("1.1.1"), {"local/amd64-addon-ssh"}
)
update_task = coresys.create_task(simulate_update())
await asyncio.sleep(0)
with patch.object(Repository, "update"):
await coresys.store.reload()
assert "image" not in coresys.store.data.addons["local_ssh"]
assert coresys.store.data.addons["local_ssh"]["version"] == AwesomeVersion("9.2.1")
event.set()
await update_task
assert install_addon_ssh.image == "test_image"
assert install_addon_ssh.version == AwesomeVersion("1.1.1")