This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


The following commit(s) were added to refs/heads/sbp by this push:
     new 3b9ec6c5 Use quarantining for several revision creation methods
3b9ec6c5 is described below

commit 3b9ec6c5161950650b9eb784aa12b9fe6495d525
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed Mar 4 14:07:38 2026 +0000

    Use quarantining for several revision creation methods
---
 atr/api/__init__.py                | 10 ++++++++--
 atr/paths.py                       |  4 ++++
 atr/post/upload.py                 | 20 ++++++++++++++++++--
 atr/ssh.py                         | 22 ++++++++++++----------
 atr/storage/writers/release.py     | 18 +++++++++++-------
 atr/tasks/quarantine.py            | 21 +++++++++++++++------
 atr/tasks/svn.py                   |  7 +++++--
 playwright/test.py                 | 21 +++++++++++++--------
 tests/e2e/announce/conftest.py     |  8 +-------
 tests/e2e/helpers.py               | 14 ++++++++++++++
 tests/e2e/vote/conftest.py         |  8 +-------
 tests/unit/test_quarantine_task.py | 21 ++++++++++++++++++---
 12 files changed, 120 insertions(+), 54 deletions(-)

diff --git a/atr/api/__init__.py b/atr/api/__init__.py
index cf2d9ebf..ad2c628b 100644
--- a/atr/api/__init__.py
+++ b/atr/api/__init__.py
@@ -1003,10 +1003,16 @@ async def release_upload(data: 
models.api.ReleaseUploadArgs) -> DictResponse:
 
     async with storage.write(asf_uid) as write:
         wacp = await write.as_project_committee_participant(data.project)
-        revision = await wacp.release.upload_file(data)
+        result = await wacp.release.upload_file(data)
+    if isinstance(result, sql.Quarantined):
+        return {
+            "endpoint": "/release/upload",
+            "quarantined": True,
+            "message": "Upload received. Archive validation in progress.",
+        }, 202
     return models.api.ReleaseUploadResults(
         endpoint="/release/upload",
-        revision=revision,
+        revision=result,
     ).model_dump(), 201
 
 
diff --git a/atr/paths.py b/atr/paths.py
index 4912d422..2c089e26 100644
--- a/atr/paths.py
+++ b/atr/paths.py
@@ -29,6 +29,10 @@ def get_attestable_dir() -> pathlib.Path:
     return pathlib.Path(config.get().ATTESTABLE_STORAGE_DIR)
 
 
+def get_cache_archives_dir() -> pathlib.Path:
+    return pathlib.Path(config.get().STATE_DIR) / "cache" / "archives"
+
+
 def get_downloads_dir() -> pathlib.Path:
     return pathlib.Path(config.get().DOWNLOADS_STORAGE_DIR)
 
diff --git a/atr/post/upload.py b/atr/post/upload.py
index 305f077f..1a88c833 100644
--- a/atr/post/upload.py
+++ b/atr/post/upload.py
@@ -84,12 +84,20 @@ async def finalise(
                     dst = path / filename
                     await aioshutil.move(str(src), str(dst))
 
-            await wacp.revision.create_revision(
+            result = await wacp.revision.create_revision_with_quarantine(
                 str(project_name), str(version_name), session.uid, 
description=description, modify=modify
             )
 
         await aioshutil.rmtree(staging_dir)
 
+        if isinstance(result, sql.Quarantined):
+            return await session.redirect(
+                get.compose.selected,
+                success="Upload received. Archive validation in progress.",
+                project_name=str(project_name),
+                version_name=str(version_name),
+            )
+
         return await session.redirect(
             get.compose.selected,
             success=f"{util.plural(number_of_files, 'file')} added 
successfully",
@@ -187,7 +195,7 @@ async def _add_files(
 
         async with storage.write(session) as write:
             wacp = await write.as_project_committee_participant(project_name)
-            creation_error, number_of_files = await wacp.release.upload_files(
+            creation_error, number_of_files, was_quarantined = await 
wacp.release.upload_files(
                 project_name, version_name, file_name, file_data
             )
 
@@ -199,6 +207,14 @@ async def _add_files(
                 version_name=version_name,
             )
 
+        if was_quarantined:
+            return await session.redirect(
+                get.compose.selected,
+                success="Upload received. Archive validation in progress.",
+                project_name=project_name,
+                version_name=version_name,
+            )
+
         return await session.redirect(
             get.compose.selected,
             success=f"{util.plural(number_of_files, 'file')} added 
successfully",
diff --git a/atr/ssh.py b/atr/ssh.py
index b7aa4be3..25714bce 100644
--- a/atr/ssh.py
+++ b/atr/ssh.py
@@ -615,18 +615,20 @@ async def _step_07b_process_validated_rsync_write(
                 raise types.FailedError(f"rsync upload failed with exit status 
{exit_status} for {for_revision}")
 
         try:
-            new_revision = await wacp.revision.create_revision(
+            result = await wacp.revision.create_revision_with_quarantine(
                 project_name, version_name, asf_uid, description=description, 
modify=modify
             )
-            github_payload = server._get_github_payload(process)
-            if github_payload is not None:
-                await attestable.github_tp_payload_write(
-                    project_name, version_name, new_revision.number, 
github_payload
-                )
-            log.info(f"rsync upload successful for revision 
{new_revision.number}")
-            host = config.get().APP_HOST
-            message = f"\nATR: Created revision {new_revision.number} of 
{project_name} {version_name}\n"
-            message += f"ATR: 
https://{host}/compose/{project_name}/{version_name}\n";
+            if isinstance(result, sql.Quarantined):
+                log.info(f"rsync upload quarantined for release 
{release_name}")
+                message = f"\nATR: Upload received for {project_name} 
{version_name}. Archive validation in progress.\n"
+            else:
+                github_payload = server._get_github_payload(process)
+                if github_payload is not None:
+                    await attestable.github_tp_payload_write(project_name, 
version_name, result.number, github_payload)
+                log.info(f"rsync upload successful for revision 
{result.number}")
+                host = config.get().APP_HOST
+                message = f"\nATR: Created revision {result.number} of 
{project_name} {version_name}\n"
+                message += f"ATR: 
https://{host}/compose/{project_name}/{version_name}\n";
             if not process.stderr.is_closing():
                 process.stderr.write(message.encode())
                 await process.stderr.drain()
diff --git a/atr/storage/writers/release.py b/atr/storage/writers/release.py
index 271d86c5..79735012 100644
--- a/atr/storage/writers/release.py
+++ b/atr/storage/writers/release.py
@@ -107,6 +107,8 @@ class CommitteeParticipant(FoundationCommitter):
         release_dirs = [
             paths.release_directory_base(release),
             paths.get_attestable_dir() / project_name / version,
+            paths.get_cache_archives_dir() / project_name / version,
+            paths.get_quarantined_dir() / project_name / version,
         ]
 
         # Delete from the database using bulk SQL DELETE for efficiency
@@ -451,7 +453,7 @@ class CommitteeParticipant(FoundationCommitter):
         )
         return release, project
 
-    async def upload_file(self, args: api.ReleaseUploadArgs) -> sql.Revision:
+    async def upload_file(self, args: api.ReleaseUploadArgs) -> sql.Revision | 
sql.Quarantined:
         file_bytes = base64.b64decode(args.content, validate=True)
         validated_path = form.to_relpath(args.relpath)
         if validated_path is None:
@@ -466,14 +468,16 @@ class CommitteeParticipant(FoundationCommitter):
             async with aiofiles.open(target_path, "wb") as f:
                 await f.write(file_bytes)
 
-        revision = await self.__write_as.revision.create_revision(
+        result = await 
self.__write_as.revision.create_revision_with_quarantine(
             args.project, args.version, self.__asf_uid, 
description=description, modify=modify
         )
+        if isinstance(result, sql.Quarantined):
+            return result
         async with db.session() as data:
             release_name = sql.release_name(args.project, args.version)
             return await data.revision(
                 release_name=release_name,
-                number=revision.number,
+                number=result.number,
             ).demand(storage.AccessError("Revision not found"))
 
     async def upload_files(
@@ -482,7 +486,7 @@ class CommitteeParticipant(FoundationCommitter):
         version_name: str,
         file_name: pathlib.Path | None,
         files: Sequence[datastructures.FileStorage],
-    ) -> tuple[str | None, int]:
+    ) -> tuple[str | None, int, bool]:
         """Process and save the uploaded files into a new draft revision."""
         number_of_files = len(files)
         description = f"Upload of {util.plural(number_of_files, 'file')} 
through web interface"
@@ -510,12 +514,12 @@ class CommitteeParticipant(FoundationCommitter):
                 await self.__save_file(file, target_path)
 
         try:
-            await self.__write_as.revision.create_revision(
+            result = await 
self.__write_as.revision.create_revision_with_quarantine(
                 project_name, version_name, self.__asf_uid, 
description=description, modify=modify
             )
         except types.FailedError as e:
-            return str(e), len(files)
-        return None, len(files)
+            return str(e), len(files), False
+        return None, len(files), isinstance(result, sql.Quarantined)
 
     async def __current_paths(self, interim_path: pathlib.Path) -> 
list[pathlib.Path]:
         all_current_paths_interim: list[pathlib.Path] = []
diff --git a/atr/tasks/quarantine.py b/atr/tasks/quarantine.py
index 9ca22bbc..083780b5 100644
--- a/atr/tasks/quarantine.py
+++ b/atr/tasks/quarantine.py
@@ -79,22 +79,27 @@ async def validate(args: QuarantineValidate) -> 
results.Results | None:
         return None
 
     try:
-        await _extract_archives_to_cache(args.archives, quarantine_dir)
+        await _extract_archives_to_cache(args.archives, quarantine_dir, 
project_name, version_name)
     except Exception:
         await _mark_failed(quarantined, file_entries, "Archive extraction to 
cache failed")
         await aioshutil.rmtree(quarantine_dir)
         return None
 
-    await _promote(quarantined, project_name, version_name, release, 
str(quarantine_dir))
+    await _promote(quarantined, project_name, version_name, release.name, 
str(quarantine_dir))
     return None
 
 
-async def _extract_archives_to_cache(archives: list[QuarantineArchiveEntry], 
quarantine_dir: pathlib.Path) -> None:
+async def _extract_archives_to_cache(
+    archives: list[QuarantineArchiveEntry],
+    quarantine_dir: pathlib.Path,
+    project_name: str,
+    version_name: str,
+) -> None:
     # Cannot import as archives because that shadows the parameter name
     import atr.archives
 
     conf = config.get()
-    cache_base = pathlib.Path(conf.STATE_DIR) / "cache" / "archives"
+    cache_base = paths.get_cache_archives_dir() / project_name / version_name
     await aiofiles.os.makedirs(cache_base, exist_ok=True)
 
     for archive in archives:
@@ -140,11 +145,15 @@ async def _promote(
     quarantined: sql.Quarantined,
     project_name: str,
     version_name: str,
-    release: sql.Release,
+    release_name: str,
     quarantine_dir: str,
 ) -> None:
     quarantine_dir_path = pathlib.Path(quarantine_dir)
-    release_name = release.name
+
+    async with db.session() as data:
+        release = await data.release(name=release_name, _release_policy=True, 
_project_release_policy=True).demand(
+            RuntimeError(f"Release {release_name} not found during quarantine 
promotion")
+        )
 
     path_to_hash, path_to_size = await 
attestable.paths_to_hashes_and_sizes(quarantine_dir_path)
 
diff --git a/atr/tasks/svn.py b/atr/tasks/svn.py
index 3679fcc0..5b6c1829 100644
--- a/atr/tasks/svn.py
+++ b/atr/tasks/svn.py
@@ -122,10 +122,13 @@ async def _import_files_core(args: SvnImport) -> str:
             await aiofiles.os.rmdir(temp_export_path)
             log.info(f"Removed temporary export directory: {temp_export_path}")
 
-        new_revision = await wacp.revision.create_revision(
+        result = await wacp.revision.create_revision_with_quarantine(
             args.project_name, args.version_name, args.asf_uid, 
description=description, modify=modify
         )
-        return f"Successfully imported files from SVN into revision 
{new_revision.number}"
+        if isinstance(result, sql.Quarantined):
+            log.info(f"SVN import quarantined for 
{args.project_name}-{args.version_name}")
+            return f"SVN import received for 
{args.project_name}-{args.version_name}. Archive validation in progress."
+        return f"Successfully imported files from SVN into revision 
{result.number}"
 
 
 async def _import_files_core_run_svn_export(svn_command: list[str], 
temp_export_path: pathlib.Path) -> None:
diff --git a/playwright/test.py b/playwright/test.py
index a82a37ae..d7e4db10 100755
--- a/playwright/test.py
+++ b/playwright/test.py
@@ -1154,17 +1154,22 @@ def test_ssh_02_rsync_upload(page: Page, credentials: 
Credentials) -> None:
         logging.error("rsync command not found. Is rsync installed in the 
container?")
         raise RuntimeError("rsync command not found")
 
-    logging.info(f"Navigating to compose page for 
{project_name}-{version_name}")
     compose_path = f"/compose/{project_name}/{version_name}"
-    go_to_path(page, compose_path)
-    logging.info(f"Checking for uploaded files on {compose_path}")
-
-    # Check for the existence of the files in the table using exact match
-    file1_locator = page.get_by_role("cell", name=file1, exact=True)
-    file2_locator = page.get_by_role("cell", name=file2, exact=True)
+    logging.info(f"Waiting for quarantine validation and files to appear on 
{compose_path}")
+    max_wait_seconds = 30
+    start_time = time.monotonic()
+    while True:
+        go_to_path(page, compose_path)
+        file1_locator = page.get_by_role("cell", name=file1, exact=True)
+        if file1_locator.count() > 0:
+            break
+        elapsed = time.monotonic() - start_time
+        if elapsed > max_wait_seconds:
+            raise TimeoutError(f"Files did not appear on {compose_path} within 
{max_wait_seconds} seconds")
+        time.sleep(1)
 
-    expect(file1_locator).to_be_visible()
     logging.info(f"Found file: {file1}")
+    file2_locator = page.get_by_role("cell", name=file2, exact=True)
     expect(file2_locator).to_be_visible()
     logging.info(f"Found file: {file2}")
     logging.info("rsync upload test completed successfully")
diff --git a/tests/e2e/announce/conftest.py b/tests/e2e/announce/conftest.py
index 71a14105..b8c8ff82 100644
--- a/tests/e2e/announce/conftest.py
+++ b/tests/e2e/announce/conftest.py
@@ -69,8 +69,7 @@ def announce_context(browser: Browser) -> 
Generator[BrowserContext]:
     page.get_by_role("button", name="Add files").click()
     page.wait_for_url(f"**/compose/{PROJECT_NAME}/{VERSION_NAME}")
 
-    helpers.visit(page, f"/compose/{PROJECT_NAME}/{VERSION_NAME}")
-    _wait_for_tasks_banner_hidden(page, timeout=60000)
+    helpers.wait_for_upload_and_tasks(page, 
f"/compose/{PROJECT_NAME}/{VERSION_NAME}", FILE_NAME)
 
     page.locator('a[title="Start a vote on this draft"]').click()
     page.wait_for_load_state()
@@ -122,8 +121,3 @@ def _poll_for_vote_thread_link(page: Page, max_attempts: 
int = 30) -> None:
             return
         time.sleep(0.5)
         page.reload()
-
-
-def _wait_for_tasks_banner_hidden(page: Page, timeout: int = 30000) -> None:
-    """Wait for all background tasks to be completed."""
-    page.wait_for_selector("#ongoing-tasks-banner", state="hidden", 
timeout=timeout)
diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py
index bf86a145..76a0a979 100644
--- a/tests/e2e/helpers.py
+++ b/tests/e2e/helpers.py
@@ -16,6 +16,7 @@
 # under the License.
 
 import os
+import time
 from typing import Any, Final
 
 from playwright.sync_api import APIRequestContext, Page
@@ -48,3 +49,16 @@ def log_in(page: Page) -> None:
 def visit(page: Page, path: str) -> None:
     page.goto(f"{_ATR_BASE_URL}{path}")
     page.wait_for_load_state()
+
+
+def wait_for_upload_and_tasks(page: Page, compose_url: str, file_name: str, 
timeout: float = 60) -> None:
+    deadline = time.monotonic() + timeout
+    while True:
+        visit(page, compose_url)
+        if page.get_by_role("cell", name=file_name, exact=True).count() > 0:
+            break
+        if time.monotonic() > deadline:
+            raise TimeoutError(f"{file_name} did not appear on {compose_url} 
within {timeout}s")
+        time.sleep(1)
+    remaining_ms = max(int((deadline - time.monotonic()) * 1000), 1000)
+    page.wait_for_selector("#ongoing-tasks-banner", state="hidden", 
timeout=remaining_ms)
diff --git a/tests/e2e/vote/conftest.py b/tests/e2e/vote/conftest.py
index b689ed69..c2d0bec5 100644
--- a/tests/e2e/vote/conftest.py
+++ b/tests/e2e/vote/conftest.py
@@ -73,8 +73,7 @@ def vote_context(browser: Browser) -> 
Generator[BrowserContext]:
     page.get_by_role("button", name="Add files").click()
     page.wait_for_url(f"**/compose/{PROJECT_NAME}/{VERSION_NAME}")
 
-    helpers.visit(page, f"/compose/{PROJECT_NAME}/{VERSION_NAME}")
-    _wait_for_tasks_banner_hidden(page, timeout=60000)
+    helpers.wait_for_upload_and_tasks(page, 
f"/compose/{PROJECT_NAME}/{VERSION_NAME}", FILE_NAME)
 
     page.locator('a[title="Start a vote on this draft"]').click()
     page.wait_for_load_state()
@@ -87,8 +86,3 @@ def vote_context(browser: Browser) -> 
Generator[BrowserContext]:
     yield context
 
     context.close()
-
-
-def _wait_for_tasks_banner_hidden(page: Page, timeout: int = 30000) -> None:
-    """Wait for all background tasks to be completed."""
-    page.wait_for_selector("#ongoing-tasks-banner", state="hidden", 
timeout=timeout)
diff --git a/tests/unit/test_quarantine_task.py 
b/tests/unit/test_quarantine_task.py
index 6f24c0f3..27119414 100644
--- a/tests/unit/test_quarantine_task.py
+++ b/tests/unit/test_quarantine_task.py
@@ -73,12 +73,24 @@ async def 
test_promote_finalises_revision_and_deletes_quarantined(tmp_path: path
     mock_safe_ctx.__aenter__ = mock.AsyncMock(return_value=mock.AsyncMock())
     mock_safe_ctx.__aexit__ = mock.AsyncMock(return_value=False)
 
+    mock_release_query = mock.MagicMock()
+    mock_release_query.demand = mock.AsyncMock(return_value=release)
+
+    mock_release_data = mock.AsyncMock()
+    mock_release_data.release = mock.MagicMock(return_value=mock_release_query)
+
+    mock_release_ctx = mock.AsyncMock()
+    mock_release_ctx.__aenter__ = 
mock.AsyncMock(return_value=mock_release_data)
+    mock_release_ctx.__aexit__ = mock.AsyncMock(return_value=False)
+
     mock_delete_data = mock.AsyncMock()
     mock_delete_data.delete = mock.AsyncMock()
     mock_delete_ctx = mock.AsyncMock()
     mock_delete_ctx.__aenter__ = mock.AsyncMock(return_value=mock_delete_data)
     mock_delete_ctx.__aexit__ = mock.AsyncMock(return_value=False)
 
+    session_calls = iter([mock_release_ctx, mock_delete_ctx])
+
     with (
         mock.patch.object(
             quarantine.attestable,
@@ -89,10 +101,13 @@ async def 
test_promote_finalises_revision_and_deletes_quarantined(tmp_path: path
         mock.patch.object(quarantine.util, "paths_to_inodes", 
return_value={"file.txt": 12345}),
         mock.patch.object(quarantine.revision, "SafeSession", 
return_value=mock_safe_ctx),
         mock.patch.object(quarantine.revision, "finalise_revision", 
new_callable=mock.AsyncMock) as mock_finalise,
-        mock.patch.object(quarantine.db, "session", 
return_value=mock_delete_ctx),
+        mock.patch.object(quarantine.db, "session", side_effect=session_calls),
     ):
-        await quarantine._promote(quarantined_row, "proj", "1.0", release, 
quarantine_dir)
+        await quarantine._promote(quarantined_row, "proj", "1.0", "proj-1.0", 
quarantine_dir)
 
+    mock_release_data.release.assert_called_once_with(
+        name="proj-1.0", _release_policy=True, _project_release_policy=True
+    )
     mock_finalise.assert_awaited_once()
     call_kwargs = mock_finalise.call_args.kwargs
     assert call_kwargs["was_quarantined"] is True
@@ -268,7 +283,7 @@ async def test_validate_success_calls_promote(tmp_path: 
pathlib.Path):
         )
 
     assert result is None
-    mock_promote.assert_awaited_once_with(row, "proj", "1.0", row.release, 
str(quarantine_dir))
+    mock_promote.assert_awaited_once_with(row, "proj", "1.0", 
row.release.name, str(quarantine_dir))
     mock_mark.assert_not_awaited()
 
 


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to