Files
Groene AIandClaude Opus 5 8f31d91959 Consolidate ci/tools/s3_helper.py onto ci/praktika/s3.py
`ci/tools/s3_helper.py` (`S3Helper`) and `ci/praktika/s3.py` (`S3`) were
independent S3 clients: separate boto3 client construction, separate local-mode
switch, separate URL construction, separate content-type policy. A fix to one
left the other wrong.

Only five `S3Helper` methods had in-tree callers, and three of them had no
praktika twin at all, so the surviving client grows the two primitives it
lacked:

  - `S3.list_prefix(s3_path, recursive=True)` replaces both `list_prefix` and
    `list_prefix_non_recursive`; `recursive=False` adds `Delimiter="/"` and
    appends `CommonPrefixes`, which is what the non-recursive variant did.
  - `S3.get_url(s3_path)` is extracted from the two places `s3.py` already
    wrote that expression inline (`copy_file_to_s3`'s tail and `_dump_urls`),
    so both now call it.

The remaining nine public `S3Helper` methods had zero callers and go with the
file, along with `compress_files.compress_file_fast`, whose only caller was
`s3_helper`'s >64 MiB zstd branch (unreachable from the one live upload).

Three consequences needed handling rather than inheriting:

`copy_file_from_s3` reports a missing object by returning False, where
`download_file` raised. `create_release.py` discarded the return value at all
three download sites, so an unguarded repoint would have turned a loud release
abort into a silently missing package. Each site now tests the return value and
raises `AssertionError` with the previous message. The test is spelled
`if not ...: raise` rather than `assert`, because an assert whose expression IS
the download deletes the download itself under `python3 -O`: `run()` clears the
package directory outside any assert, and the readiness check that would notice
the empty directory is itself a bare assert, so the step would have reported
success having transferred nothing.

The AutoReleases readiness gate reads the same bucket knob as the CreateRelease
downloader it gates: `S3_BUILDS_BUCKET`, falling back to praktika's
`S3_ARTIFACT_BUCKET`. The two run as separate jobs, so a value set on one runner
and not the other would otherwise leave the checker probing a different bucket
than the producer downloads from, and that drift would surface only at the next
scheduled release.

praktika resolves `Settings` relative to the process working directory
(`SETTINGS_DIRECTORY = "./ci/settings"`). The CherryPick workflow ran
`cherry_pick.py` with `working-directory: ci/tools`, where
`S3_BUCKET_TO_HTTP_ENDPOINT` is None and building a URL raises TypeError, so it
now runs from the repo root as praktika's other entry points already do. A
`sys.path` bootstrap does not fix that; it fixes the import, not the settings
discovery. Sibling imports still resolve because `sys.path[0]` is the script's
own directory, and `git_helper.git_runner` already normalizes to the git root,
so no git operation changes.

Behavioural deltas, all measured old-vs-new at the boto3 seam: the cache
tarball gains `ContentType: application/zstd`, and `clickhouse-builds` URLs
move from path-style to virtual-hosted (both shapes serve the live object).

Changelog category: CI Fix or Improvement (changelog entry is not required)

Related: https://github.com/ClickHouse/ClickHouse/issues/121868

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 02:41:49 +00:00

118 lines
4.0 KiB
Python

#!/usr/bin/env python3
import logging
import shutil
from pathlib import Path
from build_download_helper import DownloadException, download_build_with_progress
from compress_files import compress_fast, decompress_fast
from env_helper import S3_BUILDS_BUCKET
DOWNLOAD_RETRIES_COUNT = 5
class CacheError(Exception):
pass
class Cache:
"""a generic class for all caches
`s3` must expose `list_prefix(s3_path, recursive=)`,
`copy_file_to_s3(s3_path=, local_path=)` and `get_url(s3_path)`, each
taking one `bucket/key` string (e.g. `ci/praktika`'s `S3`); it is passed
in so this module stays dependency-free.
"""
def __init__(
self,
directory: Path,
temp_path: Path,
archive_name: str,
s3,
):
self.directory = directory
self.temp_path = temp_path
self.archive_name = archive_name
self.s3 = s3
def _download(self, url: str, ignore_error: bool = False) -> None:
self.temp_path.mkdir(parents=True, exist_ok=True)
compressed_cache = self.temp_path / self.archive_name
try:
if url.startswith("file://"):
local_s3_cache = Path(url[7:])
if local_s3_cache.is_file():
shutil.copy2(local_s3_cache, compressed_cache)
else:
logging.warning(
"The local cache file %s does not exist, creating empty directory",
local_s3_cache,
)
self.directory.mkdir(parents=True, exist_ok=True)
return
else:
download_build_with_progress(url, compressed_cache)
except DownloadException as e:
if not ignore_error:
raise CacheError(f"Failed to download {url}") from e
logging.warning("Unable downloading cache, creating empty directory")
self.directory.mkdir(parents=True, exist_ok=True)
return
# decompress the cache and check if the necessary directory is there
self.directory.parent.mkdir(parents=True, exist_ok=True)
decompress_fast(compressed_cache, self.directory.parent)
if not self.directory.exists():
if not ignore_error:
raise CacheError(
"The cache is downloaded and uncompressed, but directory "
f"{self.directory} does not exist"
)
logging.warning(
"The cache archive was successfully downloaded and "
"decompressed, but %s does not exitst. Creating empty one",
self.directory,
)
self.directory.mkdir(parents=True, exist_ok=True)
def _upload(self, s3_path: str, force_upload: bool = False) -> None:
if not force_upload:
existing_cache = self.s3.list_prefix(
f"{S3_BUILDS_BUCKET}/{s3_path}", recursive=False
)
if existing_cache:
logging.info("Remote cache %s already exist, won't reupload", s3_path)
return
logging.info("Compressing cache")
archive_path = self.temp_path / self.archive_name
compress_fast(self.directory, archive_path)
logging.info("Uploading %s to S3 path %s", archive_path, s3_path)
self.s3.copy_file_to_s3(
s3_path=f"{S3_BUILDS_BUCKET}/{s3_path}", local_path=archive_path
)
class GitHubCache(Cache):
PREFIX = "ccache/github"
def __init__(
self,
directory: Path,
temp_path: Path,
s3,
):
self.force_upload = True
super().__init__(directory, temp_path, "GitHub.tar.zst", s3)
self._url = self.s3.get_url(
f"{S3_BUILDS_BUCKET}/{self.PREFIX}/{self.archive_name}"
)
def download(self):
logging.info("Searching cache for GitHub class")
self._download(self._url, True)
def upload(self):
self._upload(f"{self.PREFIX}/{self.archive_name}", True)