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

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


The following commit(s) were added to refs/heads/main by this push:
     new 605050c  Move the code to delete releases to the storage interface
605050c is described below

commit 605050cc073e0e37f8629c521b2e951f68ea12ae
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed Sep 10 19:21:20 2025 +0100

    Move the code to delete releases to the storage interface
---
 atr/blueprints/admin/admin.py  |  63 +++++++++++++--------
 atr/blueprints/api/api.py      |  48 +---------------
 atr/db/interaction.py          |  87 -----------------------------
 atr/routes/draft.py            |  16 ++----
 atr/storage/__init__.py        |   2 +-
 atr/storage/writers/release.py | 121 +++++++++++++++++++++++++++++++++++++++++
 6 files changed, 172 insertions(+), 165 deletions(-)

diff --git a/atr/blueprints/admin/admin.py b/atr/blueprints/admin/admin.py
index e766517..a687a01 100644
--- a/atr/blueprints/admin/admin.py
+++ b/atr/blueprints/admin/admin.py
@@ -315,6 +315,13 @@ async def admin_delete_release() -> str | 
response.Response:
     """Page to delete selected releases and their associated data and files."""
     form = await DeleteReleaseForm.create_form()
 
+    web_session = await session.read()
+    if web_session is None:
+        raise base.ASFQuartException("Not authenticated", 401)
+    asf_uid = web_session.uid
+    if asf_uid is None:
+        raise base.ASFQuartException("Invalid session, uid is None", 500)
+
     if quart.request.method == "POST":
         if await form.validate_on_submit():
             form_data = await quart.request.form
@@ -324,28 +331,7 @@ async def admin_delete_release() -> str | 
response.Response:
                 await quart.flash("No releases selected for deletion.", 
"warning")
                 return 
quart.redirect(quart.url_for("admin.admin_delete_release"))
 
-            success_count = 0
-            fail_count = 0
-            error_messages = []
-
-            for release_name in releases_to_delete:
-                try:
-                    await interaction.release_delete(release_name)
-                    success_count += 1
-                except base.ASFQuartException as e:
-                    log.error("Error deleting release %s: %s", release_name, e)
-                    fail_count += 1
-                    error_messages.append(f"{release_name}: {e}")
-                except Exception as e:
-                    log.exception("Unexpected error deleting release %s:", 
release_name)
-                    fail_count += 1
-                    error_messages.append(f"{release_name}: Unexpected error 
({e})")
-
-            if success_count > 0:
-                await quart.flash(f"Successfully deleted {success_count} 
release(s).", "success")
-            if fail_count > 0:
-                errors_str = "\n".join(error_messages)
-                await quart.flash(f"Failed to delete {fail_count} 
release(s):\n{errors_str}", "error")
+            await _delete_releases(releases_to_delete, asf_uid)
 
             # Redirecting back to the deletion page will refresh the list of 
releases too
             return quart.redirect(quart.url_for("admin.admin_delete_release"))
@@ -760,6 +746,39 @@ async def _check_keys(fix: bool = False) -> str:
     return message
 
 
+async def _delete_releases(releases_to_delete: list[str], asf_uid: str) -> 
None:
+    success_count = 0
+    fail_count = 0
+    error_messages = []
+
+    for release_name in releases_to_delete:
+        try:
+            async with db.session() as data:
+                release = await data.release(name=release_name, 
_committee=True, _project=True).demand(
+                    RuntimeError(f"Release {release_name} not found")
+                )
+                if release.committee is None:
+                    raise RuntimeError(f"Release {release_name} has no 
committee")
+            async with storage.write(asf_uid) as write:
+                wafa = write.as_foundation_admin(release.committee.name)
+                await wafa.release.delete(release.project.name, 
release.version)
+            success_count += 1
+        except base.ASFQuartException as e:
+            log.error(f"Error deleting release {release_name}: {e}")
+            fail_count += 1
+            error_messages.append(f"{release_name}: {e}")
+        except Exception as e:
+            log.exception(f"Unexpected error deleting release {release_name}:")
+            fail_count += 1
+            error_messages.append(f"{release_name}: Unexpected error ({e})")
+
+    if success_count > 0:
+        await quart.flash(f"Successfully deleted {success_count} release(s).", 
"success")
+    if fail_count > 0:
+        errors_str = "\n".join(error_messages)
+        await quart.flash(f"Failed to delete {fail_count} 
release(s):\n{errors_str}", "error")
+
+
 async def _get_filesystem_dirs() -> list[str]:
     filesystem_dirs = []
     await _get_filesystem_dirs_finished(filesystem_dirs)
diff --git a/atr/blueprints/api/api.py b/atr/blueprints/api/api.py
index ea54451..dae9d8a 100644
--- a/atr/blueprints/api/api.py
+++ b/atr/blueprints/api/api.py
@@ -797,57 +797,15 @@ async def release_delete(data: 
models.api.ReleaseDeleteArgs) -> DictResponse:
     if not user.is_admin(asf_uid):
         raise exceptions.Forbidden("You do not have permission to create a 
release")
 
-    async with db.session() as db_data:
-        release_name = sql.release_name(data.project, data.version)
-        await interaction.release_delete(release_name, include_downloads=True)
-        await db_data.commit()
+    async with storage.write(asf_uid) as write:
+        wafa = write.as_foundation_admin(data.project)
+        await wafa.release.delete(data.project, data.version)
     return models.api.ReleaseDeleteResults(
         endpoint="/release/delete",
         deleted=True,
     ).model_dump(), 200
 
 
-# TODO: Duplicates the above
[email protected]("/release/draft/delete", methods=["POST"])
[email protected]
-@quart_schema.security_scheme([{"BearerAuth": []}])
-@quart_schema.validate_request(models.api.ReleaseDraftDeleteArgs)
-@quart_schema.validate_response(models.api.ReleaseDraftDeleteResults, 200)
-async def release_draft_delete(data: models.api.ReleaseDraftDeleteArgs) -> 
DictResponse:
-    """
-    Delete a draft release.
-
-    The draft release is deleted, and all of its associated metadata and files
-    are removed from the database and the filesystem. This cannot be undone.
-
-    Warning: we plan to change how draft deletion works.
-    """
-    asf_uid = _jwt_asf_uid()
-
-    async with db.session() as db_data:
-        release_name = sql.release_name(data.project, data.version)
-        release = await db_data.release(
-            name=release_name, phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, 
_committee=True
-        ).demand(exceptions.NotFound(f"Draft release '{release_name}' not 
found"))
-        if release.project.committee is None:
-            raise exceptions.NotFound("Project has no committee")
-        _committee_member_or_admin(release.project.committee, asf_uid)
-
-        # TODO: This causes "A transaction is already begun on this Session"
-        # async with data.begin():
-        # Probably due to autobegin in data.release above
-        # We pass the phase again to guard against races
-        # But the removal is not actually locked
-        await interaction.release_delete(
-            release_name, phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, 
include_downloads=False
-        )
-        await db_data.commit()
-    return models.api.ReleaseDraftDeleteResults(
-        endpoint="/release/draft/delete",
-        success=True,
-    ).model_dump(), 200
-
-
 @api.BLUEPRINT.route("/release/get/<project>/<version>")
 @quart_schema.validate_response(models.api.ReleaseGetResults, 200)
 async def release_get(project: str, version: str) -> DictResponse:
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index f7a8dad..fdb0e22 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -18,15 +18,10 @@
 import contextlib
 import datetime
 import enum
-import pathlib
 from collections.abc import AsyncGenerator, Sequence
 from typing import Any, Final
 
-import aiofiles.os
-import aioshutil
-import asfquart.base as base
 import packaging.version as version
-import quart
 import sqlalchemy
 import sqlmodel
 
@@ -215,44 +210,6 @@ async def promote_release(
     return None
 
 
-async def release_delete(
-    release_name: str, phase: db.Opt[sql.ReleasePhase] = db.NOT_SET, 
include_downloads: bool = True
-) -> None:
-    """Handle the deletion of database records and filesystem data for a 
release."""
-    async with db.session() as data:
-        release = await data.release(name=release_name, phase=phase, 
_project=True).demand(
-            base.ASFQuartException(f"Release '{release_name}' not found.", 404)
-        )
-        release_dir = util.release_directory_base(release)
-
-        # Delete from the database
-        log.info("Deleting database records for release: %s", release_name)
-        # Cascade should handle this, but we delete manually anyway
-        tasks_to_delete = await data.task(project_name=release.project.name, 
version_name=release.version).all()
-        for task in tasks_to_delete:
-            await data.delete(task)
-        log.debug("Deleted %d tasks for %s", len(tasks_to_delete), 
release_name)
-
-        checks_to_delete = await 
data.check_result(release_name=release_name).all()
-        for check in checks_to_delete:
-            await data.delete(check)
-        log.debug("Deleted %d check results for %s", len(checks_to_delete), 
release_name)
-
-        # TODO: Ensure that revisions are not deleted
-        # But this makes testing difficult
-        # Perhaps delete revisions if associated with test accounts only
-        # But we want to test actual mechanisms, not special case tests
-        # We could create uniquely named releases in tests
-        # Currently part of the discussion in #171, but should be its own issue
-        await data.delete(release)
-        log.info("Deleted release record: %s", release_name)
-        await data.commit()
-
-    if include_downloads:
-        await _delete_release_data_downloads(release)
-    await _delete_release_data_filesystem(release_dir, release_name)
-
-
 async def release_latest_vote_task(release: sql.Release) -> sql.Task | None:
     """Find the most recent VOTE_INITIATE task for this release."""
     disallowed_statuses = [sql.TaskStatus.QUEUED, sql.TaskStatus.ACTIVE]
@@ -459,50 +416,6 @@ async def user_projects(asf_uid: str, caller_data: 
db.Session | None = None) ->
     return [(p.name, p.display_name) for p in projects]
 
 
-async def _delete_release_data_downloads(release: sql.Release) -> None:
-    # Delete hard links from the downloads directory
-    finished_dir = util.release_directory(release)
-    if await aiofiles.os.path.isdir(finished_dir):
-        release_inodes = set()
-        async for file_path in util.paths_recursive(finished_dir):
-            try:
-                stat_result = await aiofiles.os.stat(finished_dir / file_path)
-                release_inodes.add(stat_result.st_ino)
-            except FileNotFoundError:
-                continue
-
-        if release_inodes:
-            downloads_dir = util.get_downloads_dir()
-            async for link_path in util.paths_recursive(downloads_dir):
-                full_link_path = downloads_dir / link_path
-                try:
-                    link_stat = await aiofiles.os.stat(full_link_path)
-                    if link_stat.st_ino in release_inodes:
-                        await aiofiles.os.remove(full_link_path)
-                        log.info(f"Deleted hard link: {full_link_path}")
-                except FileNotFoundError:
-                    continue
-
-
-async def _delete_release_data_filesystem(release_dir: pathlib.Path, 
release_name: str) -> None:
-    # Delete from the filesystem
-    try:
-        if await aiofiles.os.path.isdir(release_dir):
-            log.info("Deleting filesystem directory: %s", release_dir)
-            # Believe this to be another bug in mypy Protocol handling
-            # TODO: Confirm that this is a bug, and report upstream
-            await aioshutil.rmtree(release_dir)  # type: ignore[call-arg]
-            log.info("Successfully deleted directory: %s", release_dir)
-        else:
-            log.warning("Filesystem directory not found, skipping deletion: 
%s", release_dir)
-    except Exception as e:
-        log.exception("Error deleting filesystem directory %s:", release_dir)
-        await quart.flash(
-            f"Database records for '{release_name}' deleted, but failed to 
delete filesystem directory: {e!s}",
-            "warning",
-        )
-
-
 async def _trusted_project(repository: str, workflow_ref: str, phase: 
TrustedProjectPhase) -> sql.Project:
     # Debugging
     log.info(f"GitHub OIDC JWT payload: {repository} {workflow_ref}")
diff --git a/atr/routes/draft.py b/atr/routes/draft.py
index 77d6681..a1dbb66 100644
--- a/atr/routes/draft.py
+++ b/atr/routes/draft.py
@@ -33,7 +33,6 @@ import quart
 import atr.analysis as analysis
 import atr.construct as construct
 import atr.db as db
-import atr.db.interaction as interaction
 import atr.forms as forms
 import atr.log as log
 import atr.models.sql as sql
@@ -42,6 +41,7 @@ import atr.routes as routes
 import atr.routes.compose as compose
 import atr.routes.root as root
 import atr.routes.upload as upload
+import atr.storage as storage
 import atr.tasks.sbom as sbom
 import atr.template as template
 import atr.util as util
@@ -103,15 +103,11 @@ async def delete(session: routes.CommitterSession) -> 
response.Response:
     await session.check_access(project_name)
 
     # Delete the metadata from the database
-    async with db.session() as data:
-        async with data.begin():
-            try:
-                await interaction.release_delete(
-                    release_name, 
phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, include_downloads=False
-                )
-            except Exception as e:
-                log.exception("Error deleting candidate draft:")
-                return await session.redirect(root.index, error=f"Error 
deleting candidate draft: {e!s}")
+    async with storage.write(session.uid) as write:
+        wacp = await write.as_project_committee_member(project_name)
+        await wacp.release.delete(
+            project_name, version_name, 
phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, include_downloads=False
+        )
 
     # Delete the files on disk, including all revisions
     # We can't use util.release_directory_base here because we don't have the 
release object
diff --git a/atr/storage/__init__.py b/atr/storage/__init__.py
index cd3ad0e..1009410 100644
--- a/atr/storage/__init__.py
+++ b/atr/storage/__init__.py
@@ -217,7 +217,7 @@ class WriteAsFoundationAdmin(WriteAsCommitteeMember):
         # self.announce = writers.announce.FoundationAdmin(write, self, data, 
committee_name)
         # self.checks = writers.checks.FoundationAdmin(write, self, data, 
committee_name)
         self.keys = writers.keys.FoundationAdmin(write, self, data, 
committee_name)
-        # self.release = writers.release.FoundationAdmin(write, self, data, 
committee_name)
+        self.release = writers.release.FoundationAdmin(write, self, data, 
committee_name)
         # self.ssh = writers.ssh.FoundationAdmin(write, self, data, 
committee_name)
         # self.tokens = writers.tokens.FoundationAdmin(write, self, data, 
committee_name)
         # self.vote = writers.vote.FoundationAdmin(write, self, data, 
committee_name)
diff --git a/atr/storage/writers/release.py b/atr/storage/writers/release.py
index 1145484..bfbfea1 100644
--- a/atr/storage/writers/release.py
+++ b/atr/storage/writers/release.py
@@ -19,13 +19,21 @@
 from __future__ import annotations
 
 import datetime
+from typing import TYPE_CHECKING
+
+import aiofiles.os
+import aioshutil
 
 import atr.db as db
+import atr.log as log
 import atr.models.sql as sql
 import atr.revision as revision
 import atr.storage as storage
 import atr.util as util
 
+if TYPE_CHECKING:
+    import pathlib
+
 
 class GeneralPublic:
     def __init__(
@@ -118,6 +126,12 @@ class CommitteeParticipant(FoundationCommitter):
             project_name, version, self.__asf_uid, description=description
         ) as _creating:
             pass
+        self.__write_as.append_to_audit_log(
+            action="release.start",
+            project_name=project_name,
+            version=version,
+            created=release.created.isoformat(),
+        )
         return release, project
 
 
@@ -138,3 +152,110 @@ class CommitteeMember(CommitteeParticipant):
             raise storage.AccessError("No ASF UID")
         self.__asf_uid = asf_uid
         self.__committee_name = committee_name
+
+    async def delete(
+        self,
+        project_name: str,
+        version: str,
+        phase: db.Opt[sql.ReleasePhase] = db.NOT_SET,
+        include_downloads: bool = True,
+    ) -> str | None:
+        """Handle the deletion of database records and filesystem data for a 
release."""
+        release = await self.__data.release(
+            project_name=project_name, version=version, phase=phase, 
_project=True
+        ).demand(storage.AccessError(f"Release '{project_name} {version}' not 
found."))
+        release_dir = util.release_directory_base(release)
+
+        # Delete from the database
+        log.info(f"Deleting database records for release: {project_name} 
{version}")
+        # Cascade should handle this, but we delete manually anyway
+        tasks_to_delete = await 
self.__data.task(project_name=release.project.name, 
version_name=release.version).all()
+        for task in tasks_to_delete:
+            await self.__data.delete(task)
+        log.debug(f"Deleted {len(tasks_to_delete)} tasks for {project_name} 
{version}")
+
+        checks_to_delete = await 
self.__data.check_result(release_name=release.name).all()
+        for check in checks_to_delete:
+            await self.__data.delete(check)
+        log.debug(f"Deleted {len(checks_to_delete)} check results for 
{project_name} {version}")
+
+        # TODO: Ensure that revisions are not deleted
+        # But this makes testing difficult
+        # Perhaps delete revisions if associated with test accounts only
+        # But we want to test actual mechanisms, not special case tests
+        # We could create uniquely named releases in tests
+        # Currently part of the discussion in #171, but should be its own issue
+        await self.__data.delete(release)
+        log.info(f"Deleted release record: {project_name} {version}")
+        await self.__data.commit()
+
+        if include_downloads:
+            await self.__delete_release_data_downloads(release)
+        warning = await self.__delete_release_data_filesystem(release_dir, 
project_name, version)
+        self.__write_as.append_to_audit_log(
+            action="release.delete",
+            project_name=project_name,
+            version=version,
+            warning=warning,
+        )
+        return warning
+
+    async def __delete_release_data_downloads(self, release: sql.Release) -> 
None:
+        # Delete hard links from the downloads directory
+        finished_dir = util.release_directory(release)
+        if await aiofiles.os.path.isdir(finished_dir):
+            release_inodes = set()
+            async for file_path in util.paths_recursive(finished_dir):
+                try:
+                    stat_result = await aiofiles.os.stat(finished_dir / 
file_path)
+                    release_inodes.add(stat_result.st_ino)
+                except FileNotFoundError:
+                    continue
+
+            if release_inodes:
+                downloads_dir = util.get_downloads_dir()
+                async for link_path in util.paths_recursive(downloads_dir):
+                    full_link_path = downloads_dir / link_path
+                    try:
+                        link_stat = await aiofiles.os.stat(full_link_path)
+                        if link_stat.st_ino in release_inodes:
+                            await aiofiles.os.remove(full_link_path)
+                            log.info(f"Deleted hard link: {full_link_path}")
+                    except FileNotFoundError:
+                        continue
+
+    async def __delete_release_data_filesystem(
+        self, release_dir: pathlib.Path, project_name: str, version: str
+    ) -> str | None:
+        # Delete from the filesystem
+        try:
+            if await aiofiles.os.path.isdir(release_dir):
+                log.info("Deleting filesystem directory: %s", release_dir)
+                # Believe this to be another bug in mypy Protocol handling
+                # TODO: Confirm that this is a bug, and report upstream
+                await aioshutil.rmtree(release_dir)  # type: ignore[call-arg]
+                log.info("Successfully deleted directory: %s", release_dir)
+            else:
+                log.warning("Filesystem directory not found, skipping 
deletion: %s", release_dir)
+        except Exception as e:
+            log.exception("Error deleting filesystem directory %s:", 
release_dir)
+            return (
+                f"Database records for '{project_name} {version}' deleted,"
+                f" but failed to delete filesystem directory: {e!s}"
+            )
+        return None
+
+
+class FoundationAdmin(CommitteeMember):
+    def __init__(
+        self, write: storage.Write, write_as: storage.WriteAsFoundationAdmin, 
data: db.Session, committee_name: str
+    ) -> None:
+        super().__init__(write, write_as, data, committee_name)
+        self.__write = write
+        self.__write_as = write_as
+        self.__data = data
+        asf_uid = write.authorisation.asf_uid
+        if asf_uid is None:
+            raise storage.AccessError("No ASF UID")
+        self.__asf_uid = asf_uid
+        self.__committee_name = committee_name


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

Reply via email to