feat: add git lfs support for challenge repositories (#413)

* feat: add git lfs support for challenge repositories

Use git-lfs when downloading challenges to handle large binary files.

* Update orchestrator/src/buttercup/orchestrator/ui/competition_api/services/challenge_service.py

Co-authored-by: Ronald Eytchison <58823072+reytchison@users.noreply.github.com>

---------

Co-authored-by: Ronald Eytchison <58823072+reytchison@users.noreply.github.com>
This commit is contained in:
Henrik Brodin
2026-01-19 14:52:25 +01:00
committed by GitHub
parent 8ad6b32c51
commit a77dbf324e
3 changed files with 168 additions and 1 deletions
+2 -1
View File
@@ -28,7 +28,8 @@ RUN --mount=type=cache,target=/root/.cache/uv \
FROM python:${PYTHON_BASE} AS runtime
RUN DEBIAN_FRONTEND=noninteractive apt-get update && \
apt-get install -y git curl && \
apt-get install -y git git-lfs curl && \
git lfs install && \
rm -rf /var/lib/apt/lists/*
RUN for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do DEBIAN_FRONTEND=noninteractive apt-get remove $pkg; done || true
@@ -34,6 +34,51 @@ class ChallengeService:
self.storage_dir.mkdir(parents=True, exist_ok=True)
self.base_url = base_url
def _pull_lfs_files(self, repo_path: Path, context: str) -> None:
"""Pull LFS files if the repository uses LFS.
For GitHub repositories, LFS authentication uses the same credentials
embedded in the remote URL during clone. This works because Git stores
the full URL (including credentials) in .git/config, and LFS reads from there.
Args:
repo_path: Path to the git repository
context: Description for logging (e.g., "repo@ref")
Raises:
subprocess.CalledProcessError: If LFS files exist but pull fails
"""
# Check if repo has LFS files tracked
ls_result = subprocess.run(
["git", "lfs", "ls-files"],
cwd=repo_path,
capture_output=True,
text=True,
)
# git-lfs not installed or not available
if ls_result.returncode != 0:
logger.warning(f"[{context}] git-lfs not available: {ls_result.stderr.strip()}")
return
# No LFS files in this repo
if not ls_result.stdout.strip():
logger.debug(f"[{context}] No LFS files in repository")
return
# LFS files exist - must pull them
lfs_file_count = len(ls_result.stdout.strip().splitlines())
logger.info(f"[{context}] Pulling {lfs_file_count} LFS file(s)")
result = subprocess.run(
["git", "lfs", "pull"],
cwd=repo_path,
capture_output=True,
text=True,
check=True, # Fail if LFS pull fails - we need these files
)
logger.info(f"[{context}] LFS pull complete: {result.stdout.strip()}")
def create_challenge_tarball(
self,
repo_url: str,
@@ -111,6 +156,9 @@ class ChallengeService:
)
logger.info(f"Git checkout output: {result.stdout}")
# Pull LFS files if repository uses LFS
self._pull_lfs_files(sub_path, f"{repo_url}@{cur_ref}")
# Create tarball
tarball_path = self.storage_dir / f"{tarball_name}.tar.gz"
@@ -148,6 +196,10 @@ class ChallengeService:
check=True,
)
logger.info(f"Git checkout output: {result.stdout}")
# Pull LFS files if repository uses LFS
self._pull_lfs_files(sub_path, f"{repo_url}@{ref}")
shutil.copytree(sub_path, ref_sub_path, ignore=shutil.ignore_patterns(".git", ".aixcc"))
# Create a git-diff file between the two directories (base_sub_path and ref_sub_path)
+114
View File
@@ -445,3 +445,117 @@ class TestChallengeService:
assert broadcast.broadcasts[0].sarif == complex_sarif
assert broadcast.broadcasts[0].task_id == task_id
assert broadcast.broadcasts[0].metadata == {}
class TestPullLfsFiles:
"""Test cases for the _pull_lfs_files method."""
@pytest.fixture
def temp_storage_dir(self):
"""Create a temporary storage directory for testing."""
with tempfile.TemporaryDirectory() as temp_dir:
yield Path(temp_dir)
@pytest.fixture
def challenge_service(self, temp_storage_dir):
"""Create a ChallengeService instance with temporary storage."""
return ChallengeService(temp_storage_dir, "http://localhost:8000")
@pytest.fixture
def mock_repo_path(self, temp_storage_dir):
"""Create a mock repository path."""
repo_path = temp_storage_dir / "mock-repo"
repo_path.mkdir()
return repo_path
@patch("subprocess.run")
def test_no_lfs_files_in_repo(self, mock_run, challenge_service, mock_repo_path):
"""Test that git lfs pull is not called when no LFS files exist."""
# git lfs ls-files returns empty (no LFS files)
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
challenge_service._pull_lfs_files(mock_repo_path, "test-repo@main")
# Should only call git lfs ls-files, not git lfs pull
assert mock_run.call_count == 1
mock_run.assert_called_once_with(
["git", "lfs", "ls-files"],
cwd=mock_repo_path,
capture_output=True,
text=True,
)
@patch("subprocess.run")
def test_lfs_files_exist_pull_succeeds(self, mock_run, challenge_service, mock_repo_path):
"""Test successful LFS pull when LFS files exist."""
# First call: git lfs ls-files returns files
# Second call: git lfs pull succeeds
mock_run.side_effect = [
MagicMock(returncode=0, stdout="abc123 * large-file.bin\ndef456 * data/model.pkl\n", stderr=""),
MagicMock(returncode=0, stdout="Downloading LFS objects: 100% (2/2)", stderr=""),
]
challenge_service._pull_lfs_files(mock_repo_path, "test-repo@main")
# Should call both ls-files and pull
assert mock_run.call_count == 2
mock_run.assert_any_call(
["git", "lfs", "ls-files"],
cwd=mock_repo_path,
capture_output=True,
text=True,
)
mock_run.assert_any_call(
["git", "lfs", "pull"],
cwd=mock_repo_path,
capture_output=True,
text=True,
check=True,
)
@patch("subprocess.run")
def test_lfs_files_exist_pull_fails(self, mock_run, challenge_service, mock_repo_path):
"""Test that CalledProcessError is raised when LFS pull fails."""
# First call: git lfs ls-files returns files
# Second call: git lfs pull fails
mock_run.side_effect = [
MagicMock(returncode=0, stdout="abc123 * large-file.bin\n", stderr=""),
subprocess.CalledProcessError(1, "git lfs pull", stderr="Authentication failed"),
]
with pytest.raises(subprocess.CalledProcessError):
challenge_service._pull_lfs_files(mock_repo_path, "test-repo@main")
@patch("subprocess.run")
def test_git_lfs_not_installed(self, mock_run, challenge_service, mock_repo_path):
"""Test graceful handling when git-lfs is not installed."""
# git lfs ls-files fails because git-lfs is not installed
mock_run.return_value = MagicMock(
returncode=1,
stdout="",
stderr="git: 'lfs' is not a git command. See 'git --help'.",
)
# Should not raise, just return
challenge_service._pull_lfs_files(mock_repo_path, "test-repo@main")
# Should only call ls-files (which fails), not pull
assert mock_run.call_count == 1
@patch("subprocess.run")
def test_lfs_pull_logs_file_count(self, mock_run, challenge_service, mock_repo_path, caplog):
"""Test that the correct number of LFS files is logged."""
import logging
# Three LFS files
lfs_output = "abc123 * file1.bin\ndef456 * file2.bin\nghi789 * file3.bin\n"
mock_run.side_effect = [
MagicMock(returncode=0, stdout=lfs_output, stderr=""),
MagicMock(returncode=0, stdout="Done", stderr=""),
]
with caplog.at_level(logging.INFO):
challenge_service._pull_lfs_files(mock_repo_path, "test-repo@v1.0")
assert "Pulling 3 LFS file(s)" in caplog.text
assert "test-repo@v1.0" in caplog.text