-
Notifications
You must be signed in to change notification settings - Fork 264
Fix #634: Robust cleanup of symlinks on fetch failure + Regression Test #855
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -4,6 +4,8 @@ | |||||||||||||||||||||||||||||||||
| import hashlib | ||||||||||||||||||||||||||||||||||
| import shutil | ||||||||||||||||||||||||||||||||||
| import time | ||||||||||||||||||||||||||||||||||
| import os | ||||||||||||||||||||||||||||||||||
| import subprocess | ||||||||||||||||||||||||||||||||||
| from pathlib import Path | ||||||||||||||||||||||||||||||||||
| from typing import Optional, cast | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
|
@@ -139,8 +141,6 @@ def __init__( | |||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| async def _get_repo_lock(self): | ||||||||||||||||||||||||||||||||||
| # Previous file based implementation worked across multiple processes/threads, but wasn't fair (next acquiree is random) | ||||||||||||||||||||||||||||||||||
| # This implementation works only within the same process/thread, but is fair (next acquiree is the earliest to enter the lock) | ||||||||||||||||||||||||||||||||||
| src_id = GitPolicyFetcher.source_id(self._source) | ||||||||||||||||||||||||||||||||||
| lock = GitPolicyFetcher.repo_locks[src_id] = GitPolicyFetcher.repo_locks.get( | ||||||||||||||||||||||||||||||||||
| src_id, asyncio.Lock() | ||||||||||||||||||||||||||||||||||
|
|
@@ -153,63 +153,100 @@ async def _was_fetched_after(self, t: datetime.datetime): | |||||||||||||||||||||||||||||||||
| return False | ||||||||||||||||||||||||||||||||||
| return last_fetched > t | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| async def _attempt_atomic_sync(self, repo_path: Path, hinted_hash: Optional[str], force_fetch: bool, req_time: datetime.datetime): | ||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||
| Inner atomic function to handle the sync logic. | ||||||||||||||||||||||||||||||||||
| Isolating this allows for specific 'rollback' behaviors. | ||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||
| if self._discover_repository(repo_path): | ||||||||||||||||||||||||||||||||||
| logger.debug("Repo found at {path}", path=repo_path) | ||||||||||||||||||||||||||||||||||
| repo = self._get_valid_repo() | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| if repo is not None: | ||||||||||||||||||||||||||||||||||
| should_fetch = await self._should_fetch( | ||||||||||||||||||||||||||||||||||
| repo, | ||||||||||||||||||||||||||||||||||
| hinted_hash=hinted_hash, | ||||||||||||||||||||||||||||||||||
| force_fetch=force_fetch, | ||||||||||||||||||||||||||||||||||
| req_time=req_time, | ||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
| if should_fetch: | ||||||||||||||||||||||||||||||||||
| logger.debug(f"Fetching remote: {self._remote} ({self._source.url})") | ||||||||||||||||||||||||||||||||||
| GitPolicyFetcher.repos_last_fetched[self.source_id] = datetime.datetime.now() | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| await run_sync( | ||||||||||||||||||||||||||||||||||
| repo.remotes[self._remote].fetch, | ||||||||||||||||||||||||||||||||||
| callbacks=self._auth_callbacks, | ||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| await self._notify_on_changes(repo) | ||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||||||
| raise pygit2.GitError("Invalid repository metadata") | ||||||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||||||
| await self._clone() | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| def _perform_soft_cleanup(self, repo_path: Path): | ||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||
| Targets specific corrupted states like stale lock files or broken symlinks. | ||||||||||||||||||||||||||||||||||
| Avoids expensive full re-clones. | ||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||
| logger.info(f"Attempting soft cleanup for repo at {repo_path}") | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| # 1. Handle Symlinks specifically (Issue #634) | ||||||||||||||||||||||||||||||||||
| if os.path.islink(repo_path): | ||||||||||||||||||||||||||||||||||
| logger.warning(f"Removing broken or stale symlink at {repo_path}") | ||||||||||||||||||||||||||||||||||
| repo_path.unlink() | ||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| # 2. Handle Git Lock Files - fixing the state instead of deleting | ||||||||||||||||||||||||||||||||||
| lock_files = [ | ||||||||||||||||||||||||||||||||||
| repo_path / ".git" / "index.lock", | ||||||||||||||||||||||||||||||||||
| repo_path / ".git" / "shallow.lock", | ||||||||||||||||||||||||||||||||||
| repo_path / ".git" / "config.lock", | ||||||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| for lock_file in lock_files: | ||||||||||||||||||||||||||||||||||
| if lock_file.exists(): | ||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||
| lock_file.unlink() | ||||||||||||||||||||||||||||||||||
| logger.info(f"Removed stale git lock file: {lock_file}") | ||||||||||||||||||||||||||||||||||
| except Exception as e: | ||||||||||||||||||||||||||||||||||
| logger.error(f"Could not remove lock file {lock_file}: {e}") | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| async def fetch_and_notify_on_changes( | ||||||||||||||||||||||||||||||||||
| self, | ||||||||||||||||||||||||||||||||||
| hinted_hash: Optional[str] = None, | ||||||||||||||||||||||||||||||||||
| force_fetch: bool = False, | ||||||||||||||||||||||||||||||||||
| req_time: datetime.datetime = None, | ||||||||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||||||||
| """Makes sure the repo is already fetched and is up to date. | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| - if no repo is found, the repo will be cloned. | ||||||||||||||||||||||||||||||||||
| - if the repo is found and it is deemed out-of-date, the configured remote will be fetched. | ||||||||||||||||||||||||||||||||||
| - if after a fetch new commits are detected, a callback will be triggered. | ||||||||||||||||||||||||||||||||||
| - if the hinted commit hash is provided and is already found in the local clone | ||||||||||||||||||||||||||||||||||
| we use this hint to avoid an necessary fetch. | ||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||
| repo_lock = await self._get_repo_lock() | ||||||||||||||||||||||||||||||||||
| async with repo_lock: | ||||||||||||||||||||||||||||||||||
| with tracer.trace( | ||||||||||||||||||||||||||||||||||
| "git_policy_fetcher.fetch_and_notify_on_changes", | ||||||||||||||||||||||||||||||||||
| resource=self._scope_id, | ||||||||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||||||||
| if self._discover_repository(self._repo_path): | ||||||||||||||||||||||||||||||||||
| logger.debug("Repo found at {path}", path=self._repo_path) | ||||||||||||||||||||||||||||||||||
| repo = self._get_valid_repo() | ||||||||||||||||||||||||||||||||||
| if repo is not None: | ||||||||||||||||||||||||||||||||||
| should_fetch = await self._should_fetch( | ||||||||||||||||||||||||||||||||||
| repo, | ||||||||||||||||||||||||||||||||||
| hinted_hash=hinted_hash, | ||||||||||||||||||||||||||||||||||
| force_fetch=force_fetch, | ||||||||||||||||||||||||||||||||||
| req_time=req_time, | ||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
| if should_fetch: | ||||||||||||||||||||||||||||||||||
| logger.debug( | ||||||||||||||||||||||||||||||||||
| f"Fetching remote (force_fetch={force_fetch}): {self._remote} ({self._source.url})" | ||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
| GitPolicyFetcher.repos_last_fetched[ | ||||||||||||||||||||||||||||||||||
| self.source_id | ||||||||||||||||||||||||||||||||||
| ] = datetime.datetime.now() | ||||||||||||||||||||||||||||||||||
| await run_sync( | ||||||||||||||||||||||||||||||||||
| repo.remotes[self._remote].fetch, | ||||||||||||||||||||||||||||||||||
| callbacks=self._auth_callbacks, | ||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
| logger.debug(f"Fetch completed: {self._source.url}") | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| # New commits might be present because of a previous fetch made by another scope | ||||||||||||||||||||||||||||||||||
| await self._notify_on_changes(repo) | ||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||
| with tracer.trace( | ||||||||||||||||||||||||||||||||||
| "git_policy_fetcher.fetch_and_notify_on_changes", | ||||||||||||||||||||||||||||||||||
| resource=self._scope_id, | ||||||||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||||||||
| # Call atomic helper | ||||||||||||||||||||||||||||||||||
| await self._attempt_atomic_sync(self._repo_path, hinted_hash, force_fetch, req_time) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| except (pygit2.GitError, KeyError, subprocess.CalledProcessError) as git_err: | ||||||||||||||||||||||||||||||||||
| # Dedicated rollback: try to fix corrupted state instead of deleting | ||||||||||||||||||||||||||||||||||
| logger.warning(f"Git error detected: {git_err}. Attempting soft recovery.") | ||||||||||||||||||||||||||||||||||
| self._perform_soft_cleanup(self._repo_path) | ||||||||||||||||||||||||||||||||||
| raise git_err | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| except Exception as e: | ||||||||||||||||||||||||||||||||||
| # Broad rollback only as a last resort | ||||||||||||||||||||||||||||||||||
| logger.error(f"Critical failure syncing repo: {e}. Falling back to full cleanup.") | ||||||||||||||||||||||||||||||||||
| if self._repo_path.exists() or os.path.islink(self._repo_path): | ||||||||||||||||||||||||||||||||||
| if self._repo_path.is_symlink(): | ||||||||||||||||||||||||||||||||||
| self._repo_path.unlink() | ||||||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+242
to
244
|
||||||||||||||||||||||||||||||||||
| if self._repo_path.is_symlink(): | |
| self._repo_path.unlink() | |
| else: | |
| # Reuse soft cleanup for consistent symlink and lock-file handling | |
| self._perform_soft_cleanup(self._repo_path) | |
| # Remove any remaining directory tree as a last resort | |
| if self._repo_path.exists(): |
Copilot
AI
Dec 31, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The condition self._repo_path.exists() or os.path.islink(self._repo_path) is checked before calling is_symlink(), but is_symlink() should be checked first since broken symlinks return False for exists(). Restructure to check is_symlink() in the outer condition to properly handle broken symlinks.
| if self._repo_path.exists() or os.path.islink(self._repo_path): | |
| if self._repo_path.is_symlink(): | |
| self._repo_path.unlink() | |
| else: | |
| # repo dir exists but invalid -> we must delete the directory | |
| logger.warning( | |
| "Deleting invalid repo: {path}", path=self._repo_path | |
| ) | |
| shutil.rmtree(self._repo_path) | |
| else: | |
| logger.info("Repo not found at {path}", path=self._repo_path) | |
| shutil.rmtree(self._repo_path, ignore_errors=True) | |
| if self._repo_path.is_symlink(): | |
| self._repo_path.unlink() | |
| elif self._repo_path.exists() or os.path.islink(self._repo_path): | |
| shutil.rmtree(self._repo_path, ignore_errors=True) |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,63 @@ | ||||||||||||||||||||||
| import sys | ||||||||||||||||||||||
| import os | ||||||||||||||||||||||
| from unittest.mock import MagicMock | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| # --- WINDOWS FIX START --- | ||||||||||||||||||||||
| # The 'fcntl' library is specific to Linux, but you are running tests on Windows. | ||||||||||||||||||||||
| # We mock it here so the import doesn't crash your test. | ||||||||||||||||||||||
| # When this runs on the GitHub server (Linux), this block will be skipped. | ||||||||||||||||||||||
| if os.name == 'nt': | ||||||||||||||||||||||
| sys.modules["fcntl"] = MagicMock() | ||||||||||||||||||||||
| # --- WINDOWS FIX END --- | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| import pytest | ||||||||||||||||||||||
| import shutil | ||||||||||||||||||||||
| from pathlib import Path | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| # Ensure we can import the server package relative to this test file | ||||||||||||||||||||||
| current_dir = Path(__file__).parent | ||||||||||||||||||||||
| server_package_path = current_dir.parent.parent | ||||||||||||||||||||||
| sys.path.insert(0, str(server_package_path)) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| from opal_server.git_fetcher import GitPolicyFetcher | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
Comment on lines
+15
to
+23
|
||||||||||||||||||||||
| from pathlib import Path | |
| # Ensure we can import the server package relative to this test file | |
| current_dir = Path(__file__).parent | |
| server_package_path = current_dir.parent.parent | |
| sys.path.insert(0, str(server_package_path)) | |
| from opal_server.git_fetcher import GitPolicyFetcher | |
| from ..git_fetcher import GitPolicyFetcher |
Copilot
AI
Dec 31, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test doesn't actually exercise the code changes in git_fetcher.py. It manually replicates the cleanup logic instead of testing the actual fetch_and_notify_on_changes method. The test should mock the git operations to fail and verify that the actual error handling code performs the cleanup correctly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error message "Invalid repository metadata" is vague. Include the repository path in the error message to aid debugging, such as f"Invalid repository metadata at {repo_path}".