Compare commits

...

2 Commits

Author SHA1 Message Date
Mike Degatano
1f7aafcfd7 Fix fixture that ensures .git presence 2025-08-07 20:51:02 +00:00
Mike Degatano
ed45651fd9 Handle git dir missing in load and pull 2025-08-07 20:42:22 +00:00
3 changed files with 144 additions and 13 deletions

View File

@@ -1,9 +1,11 @@
"""Init file for Supervisor add-on Git.""" """Init file for Supervisor add-on Git."""
import asyncio import asyncio
from enum import StrEnum
import errno import errno
import functools as ft import functools as ft
import logging import logging
from os import listdir
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
@@ -20,6 +22,32 @@ from .validate import RE_REPOSITORY
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
class _RepoDirectoryStatus(StrEnum):
"""Basic directory status for repo."""
GOOD = "good"
EMPTY = "empty"
MISSING_GIT = "missing_git"
def _check_repo_directory(path: Path) -> _RepoDirectoryStatus:
"""Check repository directory.
Must be run in executor.
"""
if not path.is_dir():
return _RepoDirectoryStatus.EMPTY
if (path / ".git").is_dir():
return _RepoDirectoryStatus.GOOD
return (
_RepoDirectoryStatus.MISSING_GIT
if listdir(path)
else _RepoDirectoryStatus.EMPTY
)
class GitRepo(CoreSysAttributes): class GitRepo(CoreSysAttributes):
"""Manage Add-on Git repository.""" """Manage Add-on Git repository."""
@@ -50,9 +78,14 @@ class GitRepo(CoreSysAttributes):
async def load(self) -> None: async def load(self) -> None:
"""Init Git add-on repository.""" """Init Git add-on repository."""
if not await self.sys_run_in_executor((self.path / ".git").is_dir): match await self.sys_run_in_executor(_check_repo_directory, self.path):
# Nothing cached. Set up as fresh clone
case _RepoDirectoryStatus.EMPTY:
await self.clone() await self.clone()
return return
# Repository is corrupt. Try to reset it before loading
case _RepoDirectoryStatus.MISSING_GIT:
await self.reset()
# Load repository # Load repository
async with self.lock: async with self.lock:
@@ -175,6 +208,22 @@ class GitRepo(CoreSysAttributes):
async with self.lock: async with self.lock:
_LOGGER.info("Update add-on %s repository from %s", self.path, self.url) _LOGGER.info("Update add-on %s repository from %s", self.path, self.url)
# .git is missing, repository is corrupted. Can't continue, raise issue
if (
await self.sys_run_in_executor(_check_repo_directory, self.path)
!= _RepoDirectoryStatus.GOOD
):
self.sys_resolution.create_issue(
IssueType.CORRUPT_REPOSITORY,
ContextType.STORE,
reference=self.path.stem,
suggestions=[SuggestionType.EXECUTE_RESET],
)
raise StoreGitError(
f"Can't update {self.url} repo because git information is missing",
_LOGGER.error,
)
try: try:
git_cmd = git.Git() git_cmd = git.Git()
await self.sys_run_in_executor(git_cmd.ls_remote, "--heads", self.url) await self.sys_run_in_executor(git_cmd.ls_remote, "--heads", self.url)

View File

@@ -337,10 +337,32 @@ async def fixture_all_dbus_services(
) )
@pytest.fixture
def addon_repo_fixtures() -> dict[str, Path]:
"""Make addon repo fixtures into valid repositories and return."""
addon_repo_fixtures = Path(__file__).parent.joinpath("fixtures") / "addons"
core_repo_fixture = addon_repo_fixtures / "core"
local_repo_fixture = addon_repo_fixtures / "local"
git_repo_fixtures = addon_repo_fixtures / "git"
# Ensure each repo folder has a dummy .git
(core_repo_fixture / ".git").mkdir(exist_ok=True)
for f in os.listdir(git_repo_fixtures):
if (repo := git_repo_fixtures / f).is_dir():
(repo / ".git").mkdir(exist_ok=True)
return {
"core": core_repo_fixture,
"local": local_repo_fixture,
"git": git_repo_fixtures,
}
@pytest.fixture @pytest.fixture
async def coresys( async def coresys(
docker, docker,
dbus_session_bus, dbus_session_bus,
addon_repo_fixtures: dict[str, Path],
all_dbus_services, all_dbus_services,
aiohttp_client, aiohttp_client,
run_supervisor_state, run_supervisor_state,
@@ -393,15 +415,9 @@ async def coresys(
coresys_obj.host.network._connectivity = True coresys_obj.host.network._connectivity = True
# Fix Paths # Fix Paths
su_config.ADDONS_CORE = Path( su_config.ADDONS_CORE = addon_repo_fixtures["core"]
Path(__file__).parent.joinpath("fixtures"), "addons/core" su_config.ADDONS_LOCAL = addon_repo_fixtures["local"]
) su_config.ADDONS_GIT = addon_repo_fixtures["git"]
su_config.ADDONS_LOCAL = Path(
Path(__file__).parent.joinpath("fixtures"), "addons/local"
)
su_config.ADDONS_GIT = Path(
Path(__file__).parent.joinpath("fixtures"), "addons/git"
)
su_config.APPARMOR_DATA = Path( su_config.APPARMOR_DATA = Path(
Path(__file__).parent.joinpath("fixtures"), "apparmor" Path(__file__).parent.joinpath("fixtures"), "apparmor"
) )

View File

@@ -3,13 +3,15 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from git import GitCommandError, InvalidGitRepositoryError, NoSuchPathError from git import GitCommandError, InvalidGitRepositoryError, NoSuchPathError
import pytest import pytest
from supervisor.coresys import CoreSys from supervisor.coresys import CoreSys
from supervisor.exceptions import StoreGitCloneError, StoreGitError from supervisor.exceptions import StoreGitCloneError, StoreGitError
from supervisor.resolution.const import ContextType, IssueType, SuggestionType
from supervisor.resolution.data import Issue, Suggestion
from supervisor.store.git import GitRepo from supervisor.store.git import GitRepo
REPO_URL = "https://github.com/awesome-developer/awesome-repo" REPO_URL = "https://github.com/awesome-developer/awesome-repo"
@@ -93,6 +95,70 @@ async def test_git_load(coresys: CoreSys, tmp_path: Path):
assert mock_repo.call_count == 1 assert mock_repo.call_count == 1
@pytest.mark.usefixtures("tmp_supervisor_data", "supervisor_internet")
async def test_git_load_corrupt(coresys: CoreSys, tmp_path: Path):
"""Test git load with corrupt repo."""
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
repo_dir = tmp_path / "repo"
repo = GitRepo(coresys, repo_dir, REPO_URL)
# Pretend we have a something but not .git to force a reset
repo_dir.mkdir()
marker = repo_dir / "test.txt"
marker.touch()
def mock_clone_from(url, path, *args, **kwargs):
"""Mock to just make a .git and return."""
Path(path, ".git").mkdir()
return MagicMock()
with patch("git.Repo") as mock_repo:
mock_repo.clone_from = mock_clone_from
await repo.load()
assert mock_repo.call_count == 1
assert not marker.exists()
assert (repo_dir / ".git").is_dir()
@pytest.mark.usefixtures("tmp_supervisor_data", "supervisor_internet")
async def test_git_pull_correct(coresys: CoreSys, tmp_path: Path):
"""Test git pull with corrupt repo."""
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
repo_dir = tmp_path / "repo"
repo = GitRepo(coresys, repo_dir, REPO_URL)
# Set up a our fake repo
repo_dir.mkdir()
git_dir = repo_dir / ".git"
git_dir.mkdir()
(repo_dir / "test.txt").touch()
with patch("git.Repo"):
await repo.load()
# Make it corrupt
git_dir.rmdir()
# Check that we get an issue on pull
with pytest.raises(
StoreGitError,
match=f"Can't update {REPO_URL} repo because git information is missing",
):
await repo.pull()
assert (
Issue(
IssueType.CORRUPT_REPOSITORY, ContextType.STORE, reference=repo_dir.stem
)
in coresys.resolution.issues
)
assert (
Suggestion(
SuggestionType.EXECUTE_RESET, ContextType.STORE, reference=repo_dir.stem
)
in coresys.resolution.suggestions
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"git_errors", "git_errors",
[ [