Skip to content

Commit 6cebf52

Browse files
authored
Store reset only deletes git cache after clone was successful (#5984)
* Store reset only deletes git cache after clone was successful * Add test and fix fallback error handling * Fix when lock is grabbed
1 parent bc57deb commit 6cebf52

File tree

3 files changed

+199
-56
lines changed

3 files changed

+199
-56
lines changed

supervisor/resolution/fixups/store_execute_reset.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
"""Helpers to check and fix issues with free space."""
22

3-
from functools import partial
43
import logging
54

65
from ...coresys import CoreSys
@@ -12,7 +11,6 @@
1211
)
1312
from ...jobs.const import JobCondition
1413
from ...jobs.decorator import Job
15-
from ...utils import remove_folder
1614
from ..const import ContextType, IssueType, SuggestionType
1715
from .base import FixupBase
1816

@@ -45,13 +43,11 @@ async def process_fixup(self, reference: str | None = None) -> None:
4543
return
4644

4745
# Local add-ons are not a git repo, can't remove and re-pull
48-
if repository.git:
49-
await self.sys_run_in_executor(
50-
partial(remove_folder, folder=repository.git.path, content_only=True)
51-
)
52-
53-
# Load data again
5446
try:
47+
if repository.git:
48+
await repository.git.reset()
49+
50+
# Load data again
5551
await repository.load()
5652
except StoreError:
5753
raise ResolutionFixupError() from None

supervisor/store/git.py

Lines changed: 71 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,19 @@
22

33
from abc import ABC, abstractmethod
44
import asyncio
5+
import errno
56
import functools as ft
67
import logging
78
from pathlib import Path
9+
from tempfile import TemporaryDirectory
810

911
import git
1012

1113
from ..const import ATTR_BRANCH, ATTR_URL
1214
from ..coresys import CoreSys, CoreSysAttributes
1315
from ..exceptions import StoreGitCloneError, StoreGitError, StoreJobError
1416
from ..jobs.decorator import Job, JobCondition
15-
from ..resolution.const import ContextType, IssueType, SuggestionType
17+
from ..resolution.const import ContextType, IssueType, SuggestionType, UnhealthyReason
1618
from ..utils import remove_folder
1719
from .utils import get_hash_from_repository
1820
from .validate import RE_REPOSITORY, BuiltinRepository
@@ -88,38 +90,77 @@ async def load(self) -> None:
8890
async def clone(self) -> None:
8991
"""Clone git add-on repository."""
9092
async with self.lock:
91-
git_args = {
92-
attribute: value
93-
for attribute, value in (
94-
("recursive", True),
95-
("branch", self.branch),
96-
("depth", 1),
97-
("shallow-submodules", True),
98-
)
99-
if value is not None
100-
}
93+
await self._clone()
10194

102-
try:
103-
_LOGGER.info(
104-
"Cloning add-on %s repository from %s", self.path, self.url
105-
)
106-
self.repo = await self.sys_run_in_executor(
107-
ft.partial(
108-
git.Repo.clone_from,
109-
self.url,
110-
str(self.path),
111-
**git_args, # type: ignore
112-
)
95+
@Job(
96+
name="git_repo_reset",
97+
conditions=[JobCondition.FREE_SPACE, JobCondition.INTERNET_SYSTEM],
98+
on_condition=StoreJobError,
99+
)
100+
async def reset(self) -> None:
101+
"""Reset repository to fix issue with local copy."""
102+
# Clone into temporary folder
103+
temp_dir = await self.sys_run_in_executor(
104+
TemporaryDirectory, dir=self.sys_config.path_tmp
105+
)
106+
temp_path = Path(temp_dir.name)
107+
try:
108+
await self._clone(temp_path)
109+
110+
# Remove corrupted repo and move temp clone to its place
111+
def move_clone():
112+
remove_folder(folder=self.path)
113+
temp_path.rename(self.path)
114+
115+
async with self.lock:
116+
try:
117+
await self.sys_run_in_executor(move_clone)
118+
except OSError as err:
119+
if err.errno == errno.EBADMSG:
120+
self.sys_resolution.add_unhealthy_reason(
121+
UnhealthyReason.OSERROR_BAD_MESSAGE
122+
)
123+
raise StoreGitCloneError(
124+
f"Can't move clone due to: {err!s}", _LOGGER.error
125+
) from err
126+
finally:
127+
# Clean up temporary directory in case of error
128+
# If the folder was moved this will do nothing
129+
await self.sys_run_in_executor(temp_dir.cleanup)
130+
131+
async def _clone(self, path: Path | None = None) -> None:
132+
"""Clone git add-on repository to location."""
133+
path = path or self.path
134+
git_args = {
135+
attribute: value
136+
for attribute, value in (
137+
("recursive", True),
138+
("branch", self.branch),
139+
("depth", 1),
140+
("shallow-submodules", True),
141+
)
142+
if value is not None
143+
}
144+
145+
try:
146+
_LOGGER.info("Cloning add-on %s repository from %s", path, self.url)
147+
self.repo = await self.sys_run_in_executor(
148+
ft.partial(
149+
git.Repo.clone_from,
150+
self.url,
151+
str(path),
152+
**git_args, # type: ignore
113153
)
154+
)
114155

115-
except (
116-
git.InvalidGitRepositoryError,
117-
git.NoSuchPathError,
118-
git.CommandError,
119-
UnicodeDecodeError,
120-
) as err:
121-
_LOGGER.error("Can't clone %s repository: %s.", self.url, err)
122-
raise StoreGitCloneError() from err
156+
except (
157+
git.InvalidGitRepositoryError,
158+
git.NoSuchPathError,
159+
git.CommandError,
160+
UnicodeDecodeError,
161+
) as err:
162+
_LOGGER.error("Can't clone %s repository: %s.", self.url, err)
163+
raise StoreGitCloneError() from err
123164

124165
@Job(
125166
name="git_repo_pull",
Lines changed: 124 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,149 @@
11
"""Test evaluation base."""
22

33
# pylint: disable=import-error,protected-access
4+
import errno
45
from os import listdir
56
from pathlib import Path
6-
from unittest.mock import AsyncMock, patch
7+
from unittest.mock import PropertyMock, patch
78

9+
import pytest
10+
11+
from supervisor.config import CoreConfig
812
from supervisor.coresys import CoreSys
9-
from supervisor.resolution.const import ContextType, IssueType, SuggestionType
13+
from supervisor.exceptions import StoreGitCloneError
14+
from supervisor.resolution.const import (
15+
ContextType,
16+
IssueType,
17+
SuggestionType,
18+
UnhealthyReason,
19+
)
1020
from supervisor.resolution.data import Issue, Suggestion
1121
from supervisor.resolution.fixups.store_execute_reset import FixupStoreExecuteReset
22+
from supervisor.store.git import GitRepo
23+
from supervisor.store.repository import Repository
1224

1325

14-
async def test_fixup(coresys: CoreSys, supervisor_internet, tmp_path):
15-
"""Test fixup."""
16-
store_execute_reset = FixupStoreExecuteReset(coresys)
17-
test_repo = Path(tmp_path, "test_repo")
26+
@pytest.fixture(name="mock_addons_git", autouse=True)
27+
async def fixture_mock_addons_git(tmp_supervisor_data: Path) -> None:
28+
"""Mock addons git path."""
29+
with patch.object(
30+
CoreConfig,
31+
"path_addons_git",
32+
new=PropertyMock(return_value=tmp_supervisor_data / "addons" / "git"),
33+
):
34+
yield
1835

19-
assert store_execute_reset.auto
2036

37+
def add_store_reset_suggestion(coresys: CoreSys) -> None:
38+
"""Add suggestion for tests."""
2139
coresys.resolution.add_suggestion(
22-
Suggestion(SuggestionType.EXECUTE_RESET, ContextType.STORE, reference="test")
40+
Suggestion(
41+
SuggestionType.EXECUTE_RESET, ContextType.STORE, reference="94cfad5a"
42+
)
2343
)
2444
coresys.resolution.add_issue(
25-
Issue(IssueType.CORRUPT_REPOSITORY, ContextType.STORE, reference="test")
45+
Issue(IssueType.CORRUPT_REPOSITORY, ContextType.STORE, reference="94cfad5a")
2646
)
2747

28-
test_repo.mkdir()
29-
(test_repo / ".git").mkdir()
48+
49+
@pytest.mark.usefixtures("supervisor_internet")
50+
async def test_fixup(coresys: CoreSys):
51+
"""Test fixup."""
52+
store_execute_reset = FixupStoreExecuteReset(coresys)
53+
test_repo = coresys.config.path_addons_git / "94cfad5a"
54+
55+
assert store_execute_reset.auto
56+
57+
add_store_reset_suggestion(coresys)
58+
test_repo.mkdir(parents=True)
59+
good_marker = test_repo / ".git"
60+
(corrupt_marker := (test_repo / "corrupt")).touch()
3061
assert test_repo.exists()
62+
assert not good_marker.exists()
63+
assert corrupt_marker.exists()
3164

32-
mock_repositorie = AsyncMock()
33-
mock_repositorie.git.path = test_repo
34-
coresys.store.repositories["test"] = mock_repositorie
35-
assert len(listdir(test_repo)) > 0
65+
async def mock_clone(obj: GitRepo, path: Path | None = None):
66+
"""Mock of clone method."""
67+
path = path or obj.path
68+
await coresys.run_in_executor((path / ".git").mkdir)
3669

37-
with patch("shutil.disk_usage", return_value=(42, 42, 2 * (1024.0**3))):
70+
coresys.store.repositories["94cfad5a"] = Repository(
71+
coresys, "https://github.com/home-assistant/addons-example"
72+
)
73+
with (
74+
patch.object(GitRepo, "load"),
75+
patch.object(GitRepo, "_clone", new=mock_clone),
76+
patch("shutil.disk_usage", return_value=(42, 42, 2 * (1024.0**3))),
77+
):
3878
await store_execute_reset()
3979

40-
assert len(listdir(test_repo)) == 0
41-
assert mock_repositorie.load.called
80+
assert test_repo.exists()
81+
assert good_marker.exists()
82+
assert not corrupt_marker.exists()
4283
assert len(coresys.resolution.suggestions) == 0
4384
assert len(coresys.resolution.issues) == 0
85+
assert len(listdir(coresys.config.path_tmp)) == 0
86+
87+
88+
@pytest.mark.usefixtures("supervisor_internet")
89+
async def test_fixup_clone_fail(coresys: CoreSys):
90+
"""Test fixup does not delete cache when clone fails."""
91+
store_execute_reset = FixupStoreExecuteReset(coresys)
92+
test_repo = coresys.config.path_addons_git / "94cfad5a"
93+
94+
add_store_reset_suggestion(coresys)
95+
test_repo.mkdir(parents=True)
96+
(corrupt_marker := (test_repo / "corrupt")).touch()
97+
assert test_repo.exists()
98+
assert corrupt_marker.exists()
99+
100+
coresys.store.repositories["94cfad5a"] = Repository(
101+
coresys, "https://github.com/home-assistant/addons-example"
102+
)
103+
with (
104+
patch.object(GitRepo, "load"),
105+
patch.object(GitRepo, "_clone", side_effect=StoreGitCloneError),
106+
patch("shutil.disk_usage", return_value=(42, 42, 2 * (1024.0**3))),
107+
):
108+
await store_execute_reset()
109+
110+
assert test_repo.exists()
111+
assert corrupt_marker.exists()
112+
assert len(coresys.resolution.suggestions) == 1
113+
assert len(coresys.resolution.issues) == 1
114+
assert len(listdir(coresys.config.path_tmp)) == 0
115+
116+
117+
@pytest.mark.parametrize(
118+
("error_num", "unhealthy"), [(errno.EBUSY, False), (errno.EBADMSG, True)]
119+
)
120+
@pytest.mark.usefixtures("supervisor_internet")
121+
async def test_fixup_move_fail(coresys: CoreSys, error_num: int, unhealthy: bool):
122+
"""Test fixup cleans up clone on move fail.
123+
124+
This scenario shouldn't really happen unless something is pretty wrong with the system.
125+
It will leave the user in a bind without the git cache but at least we try to clean up tmp.
126+
"""
127+
store_execute_reset = FixupStoreExecuteReset(coresys)
128+
test_repo = coresys.config.path_addons_git / "94cfad5a"
129+
130+
add_store_reset_suggestion(coresys)
131+
test_repo.mkdir(parents=True)
132+
coresys.store.repositories["94cfad5a"] = Repository(
133+
coresys, "https://github.com/home-assistant/addons-example"
134+
)
135+
with (
136+
patch.object(GitRepo, "load"),
137+
patch.object(GitRepo, "_clone"),
138+
patch("supervisor.store.git.Path.rename", side_effect=(err := OSError())),
139+
patch("shutil.disk_usage", return_value=(42, 42, 2 * (1024.0**3))),
140+
):
141+
err.errno = error_num
142+
await store_execute_reset()
143+
144+
assert len(coresys.resolution.suggestions) == 1
145+
assert len(coresys.resolution.issues) == 1
146+
assert len(listdir(coresys.config.path_tmp)) == 0
147+
assert (
148+
UnhealthyReason.OSERROR_BAD_MESSAGE in coresys.resolution.unhealthy
149+
) is unhealthy

0 commit comments

Comments
 (0)