Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES/+pull-through-digest-failure.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed a foreign key error when a first-time pull-through download fails digest validation; the content app now reports the intended checksum error and closes the connection.
7 changes: 5 additions & 2 deletions pulpcore/content/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -1442,8 +1442,11 @@ async def finalize():
extra_data={"disable_retry_list": (DigestValidationError,)}
)
except DigestValidationError:
remote_artifact.failed_at = timezone.now()
await remote_artifact.asave()
# First-time pull-through RAs point at an unsaved ContentArtifact, so saving would
# violate the foreign key.
if not remote_artifact.content_artifact._state.adding:
remote_artifact.failed_at = timezone.now()
await remote_artifact.asave()
close_tcp_connection(request.transport._sock)
REMOTE_CONTENT_FETCH_FAILURE_COOLDOWN = settings.REMOTE_CONTENT_FETCH_FAILURE_COOLDOWN
raise RuntimeError(
Expand Down
76 changes: 76 additions & 0 deletions pulpcore/tests/unit/content/test_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
PathNotResolved,
_current_distribution,
)
from pulpcore.exceptions import DigestValidationError
from pulpcore.plugin.models import (
Artifact,
Content,
Expand Down Expand Up @@ -788,3 +789,78 @@ async def test_async_pull_through_add(ca1, monkeypatch, app_status):
await repo.adelete()
if task:
await task.adelete()


@pytest.fixture
def digest_failure_downloader(monkeypatch):
class FailingDownloader:
path = None

async def handle_data(self, data):
pass

async def finalize(self):
pass

async def run(self, extra_data=None):
raise DigestValidationError("actual", "expected", url="https://123/c123")

monkeypatch.setattr(Remote, "get_downloader", lambda self, **kwargs: FailingDownloader())
close_mock = Mock()
monkeypatch.setattr("pulpcore.content.handler.close_tcp_connection", close_mock)
return close_mock


def _failing_stream_args():
request = Mock(match_info={"path": "c123"}, http_range=Mock(start=None, stop=None))
response = web.StreamResponse()
response.write_eof = AsyncMock()
return request, response


def _assert_not_completed_cleanly(close_mock, request, response):
"""The connection is force-closed and the response is never finished for the client."""
close_mock.assert_called_once_with(request.transport._sock)
response.write_eof.assert_not_awaited()


@pytest.mark.asyncio
@pytest.mark.django_db
async def test_pull_through_digest_failure_new_content_artifact(digest_failure_downloader):
"""A failed digest on a first-time pull-through raises the checksum error, not IntegrityError."""
remote = await create_remote()
ca = ContentArtifact(relative_path="c123")
ra = RemoteArtifact(remote=remote, url="https://123/c123", content_artifact=ca, sha256="bad")
request, response = _failing_stream_args()

try:
with pytest.raises(RuntimeError, match="failed checksum validation"):
await Handler()._stream_remote_artifact(request, response, ra, save_artifact=True)
_assert_not_completed_cleanly(digest_failure_downloader, request, response)
assert ra.failed_at is None
assert not await RemoteArtifact.objects.filter(url="https://123/c123").aexists()
finally:
await remote.adelete()


@pytest.mark.asyncio
@pytest.mark.django_db
async def test_pull_through_digest_failure_existing_content_artifact(digest_failure_downloader):
"""A failed digest on an existing ContentArtifact still records failed_at."""
content = await create_content()
ca = await create_content_artifact(content)
remote = await create_remote()
ra = await RemoteArtifact.objects.acreate(
remote=remote, url="https://123/c123", content_artifact=ca, sha256="bad"
)
request, response = _failing_stream_args()

try:
with pytest.raises(RuntimeError, match="failed checksum validation"):
await Handler()._stream_remote_artifact(request, response, ra, save_artifact=True)
_assert_not_completed_cleanly(digest_failure_downloader, request, response)
await ra.arefresh_from_db()
assert ra.failed_at is not None
finally:
await content.adelete()
await remote.adelete()
Loading