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 b9bb0ef  Allow users to specify a download path for finished releases
b9bb0ef is described below

commit b9bb0ef343750daf6975d36ead612e94a0ab8a91
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed Jun 11 17:11:57 2025 +0100

    Allow users to specify a download path for finished releases
---
 atr/blueprints/admin/admin.py        | 32 ++++++++++++-
 atr/config.py                        |  2 +
 atr/construct.py                     |  1 +
 atr/routes/announce.py               | 92 +++++++++++++++++++++++++++---------
 atr/server.py                        |  3 +-
 atr/templates/announce-selected.html | 53 ++++++++++++++++++---
 atr/util.py                          | 22 +++++----
 playwright/test.py                   |  1 +
 8 files changed, 167 insertions(+), 39 deletions(-)

diff --git a/atr/blueprints/admin/admin.py b/atr/blueprints/admin/admin.py
index 8bfbc40..ebb79f8 100644
--- a/atr/blueprints/admin/admin.py
+++ b/atr/blueprints/admin/admin.py
@@ -550,7 +550,7 @@ async def ongoing_tasks(project_name: str, version_name: 
str, revision: str) ->
 async def _delete_release_data(release_name: str) -> 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).demand(
+        release = await data.release(name=release_name, _project=True).demand(
             base.ASFQuartException(f"Release '{release_name}' not found.", 404)
         )
         release_dir = util.release_directory_base(release)
@@ -572,6 +572,36 @@ async def _delete_release_data(release_name: str) -> None:
         _LOGGER.info("Deleted release record: %s", release_name)
         await data.commit()
 
+    await _delete_release_data_downloads(release)
+    await _delete_release_data_filesystem(release_dir, release_name)
+
+
+async def _delete_release_data_downloads(release: models.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)
+                        _LOGGER.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):
diff --git a/atr/config.py b/atr/config.py
index 6cd84e9..4e4e641 100644
--- a/atr/config.py
+++ b/atr/config.py
@@ -50,6 +50,7 @@ class AppConfig:
     USE_BLOCKBUSTER = False
     SECRET_KEY = decouple.config("SECRET_KEY", default=secrets.token_hex(128 
// 8))
     WTF_CSRF_ENABLED = decouple.config("WTF_CSRF_ENABLED", default=True, 
cast=bool)
+    DOWNLOADS_STORAGE_DIR = os.path.join(STATE_DIR, "downloads")
     FINISHED_STORAGE_DIR = os.path.join(STATE_DIR, "finished")
     UNFINISHED_STORAGE_DIR = os.path.join(STATE_DIR, "unfinished")
     SQLITE_DB_PATH = decouple.config("SQLITE_DB_PATH", default="atr.db")
@@ -117,6 +118,7 @@ def get() -> type[AppConfig]:
     absolute_paths = [
         (config.PROJECT_ROOT, "PROJECT_ROOT"),
         (config.STATE_DIR, "STATE_DIR"),
+        (config.DOWNLOADS_STORAGE_DIR, "DOWNLOADS_STORAGE_DIR"),
         (config.FINISHED_STORAGE_DIR, "FINISHED_STORAGE_DIR"),
         (config.UNFINISHED_STORAGE_DIR, "UNFINISHED_STORAGE_DIR"),
     ]
diff --git a/atr/construct.py b/atr/construct.py
index 7701394..413773c 100644
--- a/atr/construct.py
+++ b/atr/construct.py
@@ -67,6 +67,7 @@ async def announce_release_body(body: str, options: 
AnnounceReleaseOptions) -> s
     download_path = util.as_url(
         routes_release_view, project_name=options.project_name, 
version_name=options.version_name
     )
+    # TODO: This download_url should probably be for the proxy download 
directory, not the ATR view
     download_url = f"https://{host}{download_path}";
 
     # Perform substitutions in the body
diff --git a/atr/routes/announce.py b/atr/routes/announce.py
index 4974787..7363f0b 100644
--- a/atr/routes/announce.py
+++ b/atr/routes/announce.py
@@ -18,7 +18,8 @@
 import asyncio
 import datetime
 import logging
-from typing import TYPE_CHECKING, Any, Protocol
+import pathlib
+from typing import Any, Protocol
 
 import aiofiles.os
 import aioshutil
@@ -27,6 +28,7 @@ import sqlmodel
 import werkzeug.wrappers.response as response
 import wtforms
 
+import atr.config as config
 import atr.construct as construct
 import atr.db as db
 import atr.db.models as models
@@ -38,9 +40,6 @@ import atr.tasks.message as message
 import atr.template as template
 import atr.util as util
 
-if TYPE_CHECKING:
-    import pathlib
-
 
 class AnnounceFormProtocol(Protocol):
     """Protocol for the dynamically generated AnnounceForm."""
@@ -49,6 +48,7 @@ class AnnounceFormProtocol(Protocol):
     preview_revision: wtforms.HiddenField
     mailing_list: wtforms.RadioField
     confirm_announce: wtforms.BooleanField
+    download_path_suffix: wtforms.StringField
     subject: wtforms.StringField
     body: wtforms.TextAreaField
     submit: wtforms.SubmitField
@@ -96,12 +96,22 @@ async def selected(session: routes.CommitterSession, 
project_name: str, version_
     announce_form.subject.data = f"[ANNOUNCE] {project_display_name} 
{version_name} released"
     # The body can be changed, either from VoteTemplate or from the form
     announce_form.body.data = await 
construct.announce_release_default(project_name)
+    # The download path suffix can be changed
+    # The defaults depend on whether the project is top level or not
+    if (committee := release.project.committee) is None:
+        raise ValueError("Release has no committee")
+    top_level_project = release.project.name == util.unwrap(committee.name)
+    # These defaults are as per #136, but we allow the user to change the 
result
+    announce_form.download_path_suffix.data = (
+        "/" if top_level_project else 
f"/{release.project.name}-{release.version}/"
+    )
+    description_download_prefix = 
f"https://{config.get().APP_HOST}/downloads/{committee.name}"
+    announce_form.download_path_suffix.description = f"The URL will be 
{description_download_prefix} plus this suffix"
 
-    eventual_path = util.release_directory_eventual(release)
-    finished_path = util.get_finished_dir()
-    url_path = f"/downloads/{eventual_path.relative_to(finished_path)}/"
     return await template.render(
-        "announce-selected.html", release=release, 
announce_form=announce_form, url_path=url_path
+        "announce-selected.html",
+        release=release,
+        announce_form=announce_form,
     )
 
 
@@ -131,10 +141,10 @@ async def selected_post(
     subject = str(announce_form.subject.data)
     body = str(announce_form.body.data)
     preview_revision_number = str(announce_form.preview_revision.data)
+    download_path_suffix = _download_path_suffix_validated(announce_form)
 
-    source: str = ""
-    target: str = ""
-    source_base: pathlib.Path | None = None
+    unfinished_dir: str = ""
+    finished_dir: str = ""
 
     async with db.session(log_queries=True) as data:
         try:
@@ -146,6 +156,8 @@ async def selected_post(
                 with_revisions=True,
                 data=data,
             )
+            if (committee := release.project.committee) is None:
+                raise ValueError("Release has no committee")
 
             test_list = "user-tests"
             recipient = f"{test_list}@tooling.apache.org"
@@ -182,11 +194,11 @@ async def selected_post(
             data.add(task)
 
             # Prepare paths for file operations
-            source_base = util.release_directory_base(release)
-            source_path = source_base / release.unwrap_revision_number
-            source = str(source_path)
+            unfinished_revisions_path = util.release_directory_base(release)
+            unfinished_path = unfinished_revisions_path / 
release.unwrap_revision_number
+            unfinished_dir = str(unfinished_path)
 
-            await _promote(release, data, preview_revision_number)
+            await _promote_in_database(release, data, preview_revision_number)
             await data.commit()
 
         except (routes.FlashError, Exception) as e:
@@ -203,18 +215,22 @@ async def selected_post(
         # Do not put it in the data block after data.commit()
         # Otherwise util.release_directory() will not work
         release = await 
data.release(name=release.name).demand(RuntimeError(f"Release {release.name} 
does not exist"))
-        target_path = util.release_directory(release)
-        target = str(target_path)
-        if await aiofiles.os.path.exists(target):
+        finished_path = util.release_directory(release)
+        finished_dir = str(finished_path)
+        if await aiofiles.os.path.exists(finished_dir):
             raise routes.FlashError("Release already exists")
 
     # Ensure that the permissions of every directory are 755
-    await asyncio.to_thread(util.chmod_directories, source_path)
+    await asyncio.to_thread(util.chmod_directories, unfinished_path)
 
     try:
-        await aioshutil.move(source, target)
-        if source_base:
-            await aioshutil.rmtree(str(source_base))  # type: ignore[call-arg]
+        # Move the release files from somewhere in unfinished to somewhere in 
finished
+        # The whole finished hierarchy is write once for each directory, and 
then read only
+        # TODO: Set permissions to help enforce this, or find alternative 
methods
+        await aioshutil.move(unfinished_dir, finished_dir)
+        if unfinished_revisions_path:
+            # This removes all of the prior revisions
+            await aioshutil.rmtree(str(unfinished_revisions_path))  # type: 
ignore[call-arg]
     except Exception as e:
         logging.exception("Error during release announcement, file system 
phase:")
         return await session.redirect(
@@ -224,6 +240,8 @@ async def selected_post(
             version_name=version_name,
         )
 
+    await _hard_link_downloads(committee, finished_path, download_path_suffix)
+
     routes_release_finished = routes_release.finished  # type: ignore[has-type]
     return await session.redirect(
         routes_release_finished,
@@ -248,6 +266,7 @@ async def _create_announce_form_instance(
             validators=[wtforms.validators.InputRequired("Mailing list 
selection is required")],
             default="[email protected]",
         )
+        download_path_suffix = wtforms.StringField("Download path suffix", 
validators=[wtforms.validators.Optional()])
         confirm_announce = wtforms.BooleanField(
             "Confirm",
             validators=[wtforms.validators.DataRequired("You must confirm to 
proceed with announcement")],
@@ -260,7 +279,34 @@ async def _create_announce_form_instance(
     return form_instance
 
 
-async def _promote(release: models.Release, data: db.Session, 
preview_revision_number: str) -> None:
+def _download_path_suffix_validated(announce_form: AnnounceFormProtocol) -> 
str:
+    download_path_suffix = str(announce_form.download_path_suffix.data)
+    if (".." in download_path_suffix) or ("//" in download_path_suffix):
+        raise ValueError("Download path suffix must not contain .. or //")
+    if download_path_suffix.startswith("./"):
+        download_path_suffix = download_path_suffix[1:]
+    elif download_path_suffix == ".":
+        download_path_suffix = "/"
+    if not download_path_suffix.startswith("/"):
+        download_path_suffix = "/" + download_path_suffix
+    if not download_path_suffix.endswith("/"):
+        download_path_suffix = download_path_suffix + "/"
+    if "/." in download_path_suffix:
+        raise ValueError("Download path suffix must not contain /.")
+    return download_path_suffix
+
+
+async def _hard_link_downloads(
+    committee: models.Committee, unfinished_path: pathlib.Path, 
download_path_suffix: str
+) -> None:
+    """Hard link the release files to the downloads directory."""
+    # TODO: Rename *_dir functions to _path functions
+    downloads_base_path = util.get_downloads_dir()
+    downloads_path = downloads_base_path / committee.name / 
download_path_suffix.removeprefix("/")
+    await util.create_hard_link_clone(unfinished_path, downloads_path, 
exist_ok=True)
+
+
+async def _promote_in_database(release: models.Release, data: db.Session, 
preview_revision_number: str) -> None:
     """Promote a release preview to a release and delete its old revisions."""
     via = models.validate_instrumented_attribute
 
diff --git a/atr/server.py b/atr/server.py
index c3fe38c..61fda26 100644
--- a/atr/server.py
+++ b/atr/server.py
@@ -77,9 +77,10 @@ def app_dirs_setup(app_config: type[config.AppConfig]) -> 
None:
         raise RuntimeError(f"State directory not found: 
{app_config.STATE_DIR}")
     os.chdir(app_config.STATE_DIR)
     print(f"Working directory changed to: {os.getcwd()}")
-    util.get_unfinished_dir().mkdir(parents=True, exist_ok=True)
+    util.get_downloads_dir().mkdir(parents=True, exist_ok=True)
     util.get_finished_dir().mkdir(parents=True, exist_ok=True)
     util.get_tmp_dir().mkdir(parents=True, exist_ok=True)
+    util.get_unfinished_dir().mkdir(parents=True, exist_ok=True)
 
 
 def app_setup_api_docs(app: base.QuartApp) -> None:
diff --git a/atr/templates/announce-selected.html 
b/atr/templates/announce-selected.html
index 871a25b..67ef1c0 100644
--- a/atr/templates/announce-selected.html
+++ b/atr/templates/announce-selected.html
@@ -144,12 +144,11 @@
       </div>
     </div>
     <div class="row mb-3 pb-3 border-bottom">
-      <label class="col-sm-3 col-form-label text-sm-end">Download path</label>
-      <div class="col-md-9 mb-3">
-        <pre class="p-2 px-3 bg-light border rounded font-monospace 
overflow-auto">{{ url_path }}</pre>
-      </div>
-      <div class="col-md-9 offset-md-3">
-        <p>This value is read-only.</p>
+      {{ forms.label(announce_form.download_path_suffix, col="sm3") }}
+      <div class="col-md-9">
+        {{ forms.widget(announce_form.download_path_suffix) }}
+        {{ forms.errors(announce_form.download_path_suffix) }}
+        {{ forms.description(announce_form.download_path_suffix) }}
       </div>
     </div>
     <div class="row mb-3">
@@ -229,6 +228,48 @@
           });
 
           fetchAndUpdateAnnouncePreview();
+
+          const pathInput = document.getElementById("download_path_suffix");
+          const pathHelpText = 
document.getElementById("download_path_suffix-help");
+
+          if (pathInput && pathHelpText) {
+              const initialText = pathHelpText.textContent;
+              if (initialText.includes(" plus this suffix")) {
+                  const baseText = initialText.substring(0, 
initialText.indexOf(" plus this suffix"));
+                  let pathDebounce;
+
+                  // This must match the validation code in announce.py
+                  function updatePathHelpText() {
+                      let suffix = pathInput.value;
+                      if (suffix.includes("..") || suffix.includes("//")) {
+                          pathHelpText.textContent = "Download path suffix 
must not contain .. or //";
+                          return;
+                      }
+                      if (suffix.startsWith("./")) {
+                          suffix = suffix.substring(1);
+                      } else if (suffix === ".") {
+                          suffix = "/";
+                      }
+                      if (!suffix.startsWith("/")) {
+                          suffix = "/" + suffix;
+                      }
+                      if (!suffix.endsWith("/")) {
+                          suffix = suffix + "/";
+                      }
+                      if (suffix.includes("/.")) {
+                          pathHelpText.textContent = "Download path suffix 
must not contain /.";
+                          return;
+                      }
+                      pathHelpText.textContent = baseText + suffix;
+                  }
+
+                  pathInput.addEventListener("input", () => {
+                      clearTimeout(pathDebounce);
+                      pathDebounce = setTimeout(updatePathHelpText, 10);
+                  });
+                  updatePathHelpText();
+              }
+          }
       });
   </script>
 {% endblock javascripts %}
diff --git a/atr/util.py b/atr/util.py
index 4b23687..25aec42 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -191,17 +191,19 @@ async def content_list(
 
 
 async def create_hard_link_clone(
-    source_dir: pathlib.Path, dest_dir: pathlib.Path, do_not_create_dest_dir: 
bool = False
+    source_dir: pathlib.Path,
+    dest_dir: pathlib.Path,
+    do_not_create_dest_dir: bool = False,
+    exist_ok: bool = False,
 ) -> None:
     """Recursively create a clone of source_dir in dest_dir using hard links 
for files."""
-    # TODO: We're currently using cp -al instead
     # Ensure source exists and is a directory
     if not await aiofiles.os.path.isdir(source_dir):
         raise ValueError(f"Source path is not a directory or does not exist: 
{source_dir}")
 
     # Create destination directory
     if do_not_create_dest_dir is False:
-        await aiofiles.os.makedirs(dest_dir, exist_ok=False)
+        await aiofiles.os.makedirs(dest_dir, exist_ok=exist_ok)
 
     async def _clone_recursive(current_source: pathlib.Path, current_dest: 
pathlib.Path) -> None:
         for entry in await aiofiles.os.scandir(current_source):
@@ -321,6 +323,10 @@ async def get_asf_id_or_die() -> str:
     return web_session.uid
 
 
+def get_downloads_dir() -> pathlib.Path:
+    return pathlib.Path(config.get().DOWNLOADS_STORAGE_DIR)
+
+
 def get_finished_dir() -> pathlib.Path:
     return pathlib.Path(config.get().FINISHED_STORAGE_DIR)
 
@@ -532,11 +538,11 @@ def release_directory_base(release: models.Release) -> 
pathlib.Path:
     return base_dir / project_name / version_name
 
 
-def release_directory_eventual(release: models.Release) -> pathlib.Path:
-    """Return the path to the eventual destination of the release files."""
-    path_project = release.project.name
-    path_version = release.version
-    return get_finished_dir() / path_project / path_version
+# def release_directory_eventual(release: models.Release) -> pathlib.Path:
+#     """Return the path to the eventual destination of the release files."""
+#     path_project = release.project.name
+#     path_version = release.version
+#     return get_finished_dir() / path_project / path_version
 
 
 def release_directory_revision(release: models.Release) -> pathlib.Path | None:
diff --git a/playwright/test.py b/playwright/test.py
index 22c9a9d..bfb11de 100644
--- a/playwright/test.py
+++ b/playwright/test.py
@@ -1340,6 +1340,7 @@ def wait_for_path(page: sync_api.Page, path: str) -> None:
     parsed_url = urllib.parse.urlparse(page.url)
     if parsed_url.path != path:
         logging.error(f"Expected URL path '{path}', but got 
'{parsed_url.path}'")
+        logging.error(f"Page content:\\n{page.content()}")
         raise RuntimeError(f"Expected URL path '{path}', but got 
'{parsed_url.path}'")
     logging.info(f"Current URL: {page.url}")
 


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

Reply via email to