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]