sbp commented on code in PR #1561:
URL: 
https://github.com/apache/tooling-trusted-releases/pull/1561#discussion_r3915460490


##########
atr/storage/writers/release.py:
##########
@@ -1598,8 +1639,14 @@ async def complete_archive(
                 # blocking the release
                 await self.__data.commit()
                 return None
-            return await _archive_release(
-                self.__data, self.__write_as, self.__asf_uid, project_key, 
version_key, release
+            return await archive_release_core(
+                self.__data,
+                self.__write_as,
+                self.__asf_uid,

Review Comment:
   During CAP resolve, this is `system`. Don't we always have to use a real ASF 
UID when committing to SVN?



##########
atr/storage/writers/release.py:
##########
@@ -1773,6 +1820,119 @@ async def finalise_published_release(self, task_args: 
args.ReleaseFinalise) -> r
             message=message,
         )
 
+    async def unpublish_from_svn_execute(self, task_args: args.SvnUnpublish) 
-> results.SvnUnpublish:
+        # A None suffix means the release published to the committee dist 
root, not
+        # that there's nothing to do - the files to remove come from the 
artifact
+        # records, and the suffix only fixes the fallback directory.
+        suffix = task_args.download_path_suffix
+        publish_url = config.get().SVN_PUBLISH_URL
+        if not publish_url:
+            # The publish target went away between queuing and running, so fail
+            # cleanly rather than let the URL builder raise a bare ValueError.
+            raise datatypes.FailedError("SVN_PUBLISH_URL is not configured")
+        release = await self.__data.release(
+            project_key=str(task_args.project_key),
+            version=str(task_args.version_key),
+            _committee=True,
+        ).demand(datatypes.FailedError("Archived release not found for 
removal"))
+        committee = release.committee
+        if committee is None:
+            raise datatypes.FailedError("Release has no committee - Invalid 
state")
+
+        # The removal undoes the publish: take out only the files ATR recorded 
for this
+        # release - the artifact, its signature, checksum and SBOM - grouped 
under the
+        # directory each lives in. A directory the release owns outright is 
taken whole,
+        # which clears its files, any subdirectories and the emptied directory 
in one
+        # action; the committee's shared dist root only ever gives up our own 
files.
+        artifacts = await self.__data.artifact(
+            project_key=str(task_args.project_key), 
version=str(task_args.version_key)
+        ).all()
+        release_dir = str(paths.committee_dist_relpath(committee, suffix))
+        committee_root = str(paths.committee_dist_relpath(committee))
+        wanted: dict[str, set[str]] = {}
+        for artifact in artifacts:
+            directory = artifact.download_path_suffix or release_dir
+            for name in (artifact.artifact_path, artifact.signature_path, 
artifact.checksum_path, artifact.sbom_path):
+                if name:
+                    wanted.setdefault(directory, set()).add(name)
+        if not wanted:
+            return results.SvnUnpublish(
+                kind="svn_unpublish", svn_revision=None, message=f"No 
published files recorded for {release.key}"
+            )
+
+        root_url = publish_url.rstrip("/")
+        rel_paths = await self.__plan_removals(root_url, committee_root, 
wanted)
+        if not rel_paths:
+            return results.SvnUnpublish(
+                kind="svn_unpublish", svn_revision=None, 
message=f"{release.key} was already absent from dist"
+            )
+
+        log_message = (
+            f"Unpublish 
{task_args.project_key!s}-{task_args.version_key!s}\n\n"
+            f"Committee: {committee.key}\n"
+            "Tool: ATR\n"
+            f"Archived by {task_args.asf_uid} via ATR"
+        )
+        try:
+            output = await svn.remove_files(root_url, rel_paths, 
task_args.asf_uid, log_message)
+        except svn.CommandExecutionError as exc:
+            log.error(f"SVN unpublish failed: {svn.error_message(exc)}")
+            raise datatypes.FailedError(svn.error_message(exc)) from None
+        revision = svn.parse_svnmucc_revision(output)
+        self.__write_as.append_to_audit_log(
+            asf_uid=task_args.asf_uid,
+            project_key=str(task_args.project_key),
+            version=str(task_args.version_key),
+            svn_revision=revision,
+        )
+        landed = f" as r{revision}" if revision is not None else ""
+        return results.SvnUnpublish(
+            kind="svn_unpublish",
+            svn_revision=revision,
+            message=f"Removed {len(rel_paths)} file(s) for {release.key} from 
SVN{landed}",
+        )
+
+    async def __plan_removals(self, root_url: str, committee_root: str, 
wanted: dict[str, set[str]]) -> list[str]:
+        # Keep only what's still in the dist area, so a re-run or a partial 
earlier
+        # removal is idempotent and svnmucc - which is atomic - never fails 
the whole
+        # commit on a path that has already gone. A genuine not-found means 
the path is
+        # already gone; any other error (a connection or auth failure) is no 
such proof,
+        # so it stays a failure.
+        rel_paths: list[str] = []
+        for directory, names in sorted(wanted.items()):
+            dir_url = f"{root_url}/{directory}"
+            if directory == committee_root:
+                # The shared dist root holds KEYS and other releases, so never 
take the
+                # directory itself, only our own files still in it. 
Non-recursive: a flat
+                # root may hold thousands of releases there's no reason to 
walk.
+                present = await self.__list_present(dir_url, recursive=False)

Review Comment:
   If a committee publishes a release straight into their committee directory, 
with no prefix, and there are subdirectories, `recursive=False` means that the 
listing call here won't find those files. A recursive list in SVN can still be 
done with a single `svn list` command, but it's true that the output might be 
huge.



##########
atr/storage/writers/release.py:
##########
@@ -1773,6 +1820,119 @@ async def finalise_published_release(self, task_args: 
args.ReleaseFinalise) -> r
             message=message,
         )
 
+    async def unpublish_from_svn_execute(self, task_args: args.SvnUnpublish) 
-> results.SvnUnpublish:
+        # A None suffix means the release published to the committee dist 
root, not
+        # that there's nothing to do - the files to remove come from the 
artifact
+        # records, and the suffix only fixes the fallback directory.
+        suffix = task_args.download_path_suffix
+        publish_url = config.get().SVN_PUBLISH_URL
+        if not publish_url:
+            # The publish target went away between queuing and running, so fail
+            # cleanly rather than let the URL builder raise a bare ValueError.
+            raise datatypes.FailedError("SVN_PUBLISH_URL is not configured")
+        release = await self.__data.release(
+            project_key=str(task_args.project_key),
+            version=str(task_args.version_key),
+            _committee=True,
+        ).demand(datatypes.FailedError("Archived release not found for 
removal"))
+        committee = release.committee
+        if committee is None:
+            raise datatypes.FailedError("Release has no committee - Invalid 
state")
+
+        # The removal undoes the publish: take out only the files ATR recorded 
for this
+        # release - the artifact, its signature, checksum and SBOM - grouped 
under the
+        # directory each lives in. A directory the release owns outright is 
taken whole,
+        # which clears its files, any subdirectories and the emptied directory 
in one
+        # action; the committee's shared dist root only ever gives up our own 
files.

Review Comment:
   This can fail if there are other files in there. Here's an example that I 
had an agent actually run with this PR code:
   
   ```
   PUBLISHED to project/1.0.0 by svn import:
     CHANGES.txt
     README.md
     apache-project-1.0.0-src.tar.gz
     apache-project-1.0.0-src.tar.gz.asc
     apache-project-1.0.0-src.tar.gz.cdx.json
     apache-project-1.0.0-src.tar.gz.cdx.json.asc
     apache-project-1.0.0-src.tar.gz.cdx.json.sha512
     apache-project-1.0.0-src.tar.gz.sha256
     apache-project-1.0.0-src.tar.gz.sha512
   RECORDED by announce in 1 Artifact row(s), 
download_path_suffix='project/1.0.0':
     apache-project-1.0.0-src.tar.gz
     apache-project-1.0.0-src.tar.gz.asc
     apache-project-1.0.0-src.tar.gz.cdx.json
     apache-project-1.0.0-src.tar.gz.sha512
   TASK RESULT: Removed 4 file(s) for project-1.0.0 from SVN as r2
   LEFT IN SVN under project/ after unpublish:
     1.0.0/
     1.0.0/CHANGES.txt
     1.0.0/README.md
     1.0.0/apache-project-1.0.0-src.tar.gz.cdx.json.asc
     1.0.0/apache-project-1.0.0-src.tar.gz.cdx.json.sha512
     1.0.0/apache-project-1.0.0-src.tar.gz.sha256
   ```
   
   There's an existing test for this that should prevent it, but it happens to 
use exactly the subset of files that _doesn't_ cause this problem. Just adding 
something like a `README.md` file or a checksum for the SBOM JSON would break 
it.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to