Files
TheRock/build_tools/_therock_utils/storage_backend.py
T
derek.holloway 7502bf64a4
CodeQL / Analyze (actions) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Multi-Arch CI / setup (push) Canceled after 0s
Publish build_manylinux_x86_64 images / publish_build_manylinux_x86_64 (push) Canceled after 0s
Setup (Multi-Arch) / setup (push) Canceled after 0s
Multi-Arch CI / Linux::${{ fromJSON(needs.setup.outputs.linux_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubi10 image / publish_no_rocm_image_ubi10 (push) Canceled after 0s
Multi-Arch CI / Windows::${{ fromJSON(needs.setup.outputs.windows_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Multi-Arch CI / Manifest Diff (push) Canceled after 0s
TheRock Manifest Diff Report / Generate Manifest Diff Report (push) Canceled after 0s
Multi-Arch CI / CI Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_ocl_rt images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_rocgdb images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Unit Tests / Unit Tests :: ubuntu-24.04 (push) Canceled after 0s
Unit Tests / Unit Tests :: windows-2022 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_media (push) Canceled after 0s
Unit Tests / Unit Tests Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_openmpi (push) Canceled after 0s
Flatten subprojects into a single base monorepo
2026-09-02 16:34:47 -07:00

554 lines
19 KiB
Python

"""Storage backend abstraction for writing to S3 and local directories.
Provides a unified interface for writing files to storage — uploading
local files, copying between storage locations, etc. Content types for
known file extensions are set automatically during upload.
Usage::
from _therock_utils.storage_backend import create_storage_backend
backend = create_storage_backend() # S3
backend = create_storage_backend(staging_dir=Path("/tmp/out")) # local
backend = create_storage_backend(dry_run=True) # print only
# Upload local files
backend.upload_file(source, dest_location)
backend.upload_directory(source_dir, dest_location, include=["*.tar.xz*"])
# Copy between storage locations (e.g. S3-to-S3 promotion)
backend.copy_file(source_location, dest_location)
"""
import concurrent.futures
import fnmatch
import logging
import mimetypes
import os
import shutil
import time
from abc import ABC, abstractmethod
from pathlib import Path
from _therock_utils.storage_location import StorageLocation
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Content-type inference
# ---------------------------------------------------------------------------
# Explicit content-type overrides for extensions we know about.
_CONTENT_TYPE_OVERRIDES: dict[str, str] = {
".gz": "application/gzip",
".html": "text/html",
".log": "text/plain",
".md": "text/plain",
".whl": "application/zip",
".xz": "application/x-xz",
".zst": "application/zstd",
}
_DEFAULT_CONTENT_TYPE = "application/octet-stream"
def infer_content_type(path: Path) -> str:
"""Infer MIME content-type from a file's extension.
Uses explicit overrides for extensions we know about, falling back
to ``mimetypes`` for everything else.
"""
suffix = path.suffix.lower()
if suffix in _CONTENT_TYPE_OVERRIDES:
return _CONTENT_TYPE_OVERRIDES[suffix]
guessed, _ = mimetypes.guess_type(path.name)
return guessed or _DEFAULT_CONTENT_TYPE
# ---------------------------------------------------------------------------
# StorageBackend ABC
# ---------------------------------------------------------------------------
class StorageBackend(ABC):
"""Abstract base class for storage operations."""
@abstractmethod
def upload_file(self, source: Path, dest: StorageLocation) -> None:
"""Upload a single local file to the given destination."""
...
@abstractmethod
def copy_file(self, source: StorageLocation, dest: StorageLocation) -> None:
"""Copy a file between two storage locations.
For S3 backends this is a server-side copy (no local download).
For local backends this copies between local paths.
"""
...
@abstractmethod
def list_files(
self,
location: StorageLocation,
include: list[str] | None = None,
) -> list[StorageLocation]:
"""List files at a storage location.
Args:
location: The directory location to list.
include: Optional glob patterns matched against each file's
path relative to *location* (e.g. ``["*.tar.gz"]``).
Uses ``fnmatch`` semantics (``*`` matches across ``/``).
If ``None``, all files are listed.
Returns:
List of ``StorageLocation`` objects for each matching file.
"""
...
def copy_files(self, files: list[tuple[StorageLocation, StorageLocation]]) -> int:
"""Copy multiple files between storage locations.
The base implementation copies sequentially. Subclasses may
override to copy in parallel (see ``S3StorageBackend``).
Args:
files: List of ``(source, destination)`` pairs.
Returns:
Number of files copied.
"""
for source, dest in files:
self.copy_file(source, dest)
return len(files)
def copy_directory(
self,
source: StorageLocation,
dest: StorageLocation,
include: list[str] | None = None,
) -> int:
"""Copy files from *source* to *dest*, preserving relative paths.
Lists files at the source location, optionally filters by *include*
patterns, and copies each file to the destination with its path
relative to *source* preserved.
Args:
source: Source directory location.
dest: Destination directory location.
include: Optional glob patterns to filter filenames.
Returns:
Number of files copied.
"""
files = self.list_files(source, include=include)
# list_files returns full keys. Strip the source prefix to get the
# path relative to the source directory, then prepend the dest prefix.
#
# Example with source="12345-linux/tarballs", dest="v3":
# list_files returns: "12345-linux/tarballs/foo.tar.gz"
# strip prefix: "foo.tar.gz"
# dest key: "v3/foo.tar.gz"
#
# With nested paths (source="run-1/packages", dest="v3"):
# list_files returns: "run-1/packages/gfx94X/rocm.whl"
# strip prefix: "gfx94X/rocm.whl"
# dest key: "v3/gfx94X/rocm.whl"
source_prefix = source.relative_path
if not source_prefix.endswith("/"):
source_prefix = source_prefix + "/"
pairs = []
for f in files:
rel = f.relative_path.removeprefix(source_prefix)
dest_loc = StorageLocation(dest.bucket, f"{dest.relative_path}/{rel}")
pairs.append((f, dest_loc))
logger.info(
"copy_directory: %s -> %s/%s (%d files)",
source.s3_uri,
dest.bucket,
dest.relative_path,
len(pairs),
)
for src, dst in pairs:
logger.info(
" %s -> %s", src.relative_path.removeprefix(source_prefix), dst.s3_uri
)
return self.copy_files(pairs)
def upload_files(self, files: list[tuple[Path, StorageLocation]]) -> int:
"""Upload multiple files.
The base implementation uploads sequentially. Subclasses may
override to upload in parallel (see ``S3StorageBackend``).
Args:
files: List of ``(local_source, destination)`` pairs.
Returns:
Number of files uploaded.
"""
for source, dest in files:
self.upload_file(source, dest)
return len(files)
def upload_directory(
self,
source_dir: Path,
dest: StorageLocation,
include: list[str] | None = None,
exclude: list[str] | None = None,
) -> int:
"""Upload files from *source_dir* to *dest*, preserving relative paths.
Args:
source_dir: Local directory to upload from.
dest: Destination location (the directory root in the backend).
include: Optional glob patterns to filter files (e.g.
``["*.tar.xz*"]``). If ``None``, all files are uploaded.
exclude: Optional glob patterns to reject files (e.g.
``["ccache/*"]``). Applied after *include*. Matched against
the file's path relative to *source_dir*.
Returns:
Number of files uploaded.
Symlinks are skipped. Subdirectory structure is preserved.
"""
if not source_dir.is_dir():
raise FileNotFoundError(f"Source directory not found: {source_dir}")
patterns = include or ["*"]
files: set[Path] = set()
for pattern in patterns:
files.update(source_dir.rglob(pattern))
if exclude:
excluded: set[Path] = set()
for pattern in exclude:
excluded.update(source_dir.rglob(pattern))
files -= excluded
sorted_files = sorted(f for f in files if f.is_file() and not f.is_symlink())
file_list = [
(
f,
StorageLocation(
dest.bucket,
f"{dest.relative_path}/{f.relative_to(source_dir).as_posix()}",
),
)
for f in sorted_files
]
logger.info(
"upload_directory: %s -> %s/%s (%d files)",
source_dir,
dest.bucket,
dest.relative_path,
len(file_list),
)
for f, loc in file_list:
logger.info(" %s", f.relative_to(source_dir).as_posix())
return self.upload_files(file_list)
# ---------------------------------------------------------------------------
# S3StorageBackend
# ---------------------------------------------------------------------------
# Retry parameters for transient S3 errors.
_S3_MAX_RETRIES = 3
_S3_INITIAL_BACKOFF_SECONDS = 1.0
# Default number of concurrent uploads — matches the AWS CLI default.
_S3_DEFAULT_UPLOAD_CONCURRENCY = 10
def _s3_retry(operation: str, location: str, func, *args, **kwargs):
"""Call *func* with retries and exponential backoff on failure."""
last_exc: Exception | None = None
for attempt in range(_S3_MAX_RETRIES):
try:
return func(*args, **kwargs)
except Exception as exc:
last_exc = exc
if attempt < _S3_MAX_RETRIES - 1:
wait = _S3_INITIAL_BACKOFF_SECONDS * (2**attempt)
logger.warning(
"S3 %s attempt %d/%d failed for %s: %s. Retrying in %.1fs...",
operation,
attempt + 1,
_S3_MAX_RETRIES,
location,
exc,
wait,
)
time.sleep(wait)
raise RuntimeError(
f"S3 {operation} failed after {_S3_MAX_RETRIES} attempts: {location}"
) from last_exc
class S3StorageBackend(StorageBackend):
"""S3 storage backend using boto3."""
def __init__(
self,
*,
dry_run: bool = False,
upload_concurrency: int = _S3_DEFAULT_UPLOAD_CONCURRENCY,
):
self._dry_run = dry_run
self._upload_concurrency = upload_concurrency
self._s3_client = None
@property
def s3_client(self):
"""Lazy-initialized boto3 S3 client.
Credentials are resolved through boto3's default credential chain
(see https://docs.aws.amazon.com/boto3/latest/guide/credentials.html).
Relevant locations are checked in order:
1. Environment variables (``AWS_ACCESS_KEY_ID``,
``AWS_SECRET_ACCESS_KEY``, ``AWS_SESSION_TOKEN``)
2. Assume role providers
3. Shared credentials file (``AWS_SHARED_CREDENTIALS_FILE``)
Unlike `S3Backend` in `artifact_backend.py`, this class does
**not** fall back to `signature_version=UNSIGNED` when no
credentials are found. StorageBackend is used for uploads, which
always require authentication. If we later add file listing or
downloading support (and not just upload/copy), this should be changed.
"""
if self._s3_client is None:
import boto3
from botocore.config import Config
self._s3_client = boto3.client(
"s3",
config=Config(max_pool_connections=self._upload_concurrency),
)
return self._s3_client
def list_files(
self,
location: StorageLocation,
include: list[str] | None = None,
) -> list[StorageLocation]:
prefix = location.relative_path
# Ensure prefix ends with / for directory listing (unless empty).
if prefix and not prefix.endswith("/"):
prefix = prefix + "/"
paginator = self.s3_client.get_paginator("list_objects_v2")
results: list[StorageLocation] = []
for page in paginator.paginate(Bucket=location.bucket, Prefix=prefix):
for obj in page.get("Contents", []):
key = obj["Key"]
# Path relative to the listing prefix, used for include
# filtering. Matches rglob semantics in upload_directory.
# Keys ending in / are S3 directory markers — skip them.
rel = key.removeprefix(prefix)
if not rel or rel.endswith("/"):
continue
if include and not any(fnmatch.fnmatch(rel, p) for p in include):
continue
results.append(StorageLocation(location.bucket, key))
return results
def copy_files(self, files: list[tuple[StorageLocation, StorageLocation]]) -> int:
"""Copy multiple files in parallel.
Uses a ``ThreadPoolExecutor`` with *upload_concurrency* workers.
Each individual file copy retries internally via ``_s3_retry``.
If any files still fail after retries, a ``RuntimeError`` is
raised listing the failures.
"""
if not files:
return 0
if self._dry_run or len(files) == 1:
return super().copy_files(files)
failed: list[tuple[StorageLocation, BaseException]] = []
with concurrent.futures.ThreadPoolExecutor(
max_workers=self._upload_concurrency,
) as pool:
future_to_dest = {
pool.submit(self.copy_file, src, dst): dst for src, dst in files
}
for future in concurrent.futures.as_completed(future_to_dest):
dest = future_to_dest[future]
try:
future.result()
except Exception as exc:
failed.append((dest, exc))
if failed:
first_loc, first_exc = failed[0]
raise RuntimeError(
f"Failed to copy {len(failed)}/{len(files)} files. "
f"First failure: {first_loc.s3_uri}: {first_exc}"
)
return len(files)
def upload_files(self, files: list[tuple[Path, StorageLocation]]) -> int:
"""Upload multiple files in parallel.
Uses a ``ThreadPoolExecutor`` with *upload_concurrency* workers.
Each individual file upload retries internally via ``_s3_retry``.
If any files still fail after retries, a ``RuntimeError`` is
raised listing the failures.
"""
if not files:
return 0
if self._dry_run or len(files) == 1:
return super().upload_files(files)
failed: list[tuple[StorageLocation, BaseException]] = []
with concurrent.futures.ThreadPoolExecutor(
max_workers=self._upload_concurrency,
) as pool:
future_to_dest = {
pool.submit(self.upload_file, src, dst): dst for src, dst in files
}
for future in concurrent.futures.as_completed(future_to_dest):
dest = future_to_dest[future]
try:
future.result()
except Exception as exc:
failed.append((dest, exc))
if failed:
first_loc, first_exc = failed[0]
raise RuntimeError(
f"Failed to upload {len(failed)}/{len(files)} files. "
f"First failure: {first_loc.s3_uri}: {first_exc}"
)
return len(files)
def upload_file(self, source: Path, dest: StorageLocation) -> None:
content_type = infer_content_type(source)
if self._dry_run:
logger.info("[DRY RUN] %s -> %s (%s)", source, dest.s3_uri, content_type)
return
logger.debug("upload %s -> %s (%s)", source, dest.s3_uri, content_type)
_s3_retry(
"upload",
dest.s3_uri,
self.s3_client.upload_file,
str(source),
dest.bucket,
dest.relative_path,
ExtraArgs={"ContentType": content_type},
)
def copy_file(self, source: StorageLocation, dest: StorageLocation) -> None:
if self._dry_run:
logger.info("[DRY RUN] copy %s -> %s", source.s3_uri, dest.s3_uri)
return
copy_source = {"Bucket": source.bucket, "Key": source.relative_path}
# Use the managed transfer copy() instead of copy_object() to
# support files larger than 5GB via automatic multipart copy.
_s3_retry(
"copy",
f"{source.s3_uri} -> {dest.s3_uri}",
self.s3_client.copy,
copy_source,
dest.bucket,
dest.relative_path,
)
# ---------------------------------------------------------------------------
# LocalStorageBackend
# ---------------------------------------------------------------------------
class LocalStorageBackend(StorageBackend):
"""Local filesystem storage backend.
Mirrors the remote directory layout under *staging_dir* so that
downstream tools can be tested against a local file tree.
"""
def __init__(self, staging_dir: Path, *, dry_run: bool = False):
self._staging_dir = staging_dir
self._dry_run = dry_run
def list_files(
self,
location: StorageLocation,
include: list[str] | None = None,
) -> list[StorageLocation]:
base = location.local_path(self._staging_dir)
if not base.is_dir():
return []
patterns = include or ["*"]
files: set[Path] = set()
for p in patterns:
files.update(base.rglob(p))
return sorted(
(
StorageLocation(
location.bucket,
f"{location.relative_path}/{f.relative_to(base).as_posix()}",
)
for f in files
if f.is_file()
),
key=lambda loc: loc.relative_path,
)
def upload_file(self, source: Path, dest: StorageLocation) -> None:
target = dest.local_path(self._staging_dir)
if self._dry_run:
logger.info("[DRY RUN] %s -> %s", source, target)
return
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
def copy_file(self, source: StorageLocation, dest: StorageLocation) -> None:
src = source.local_path(self._staging_dir)
dst = dest.local_path(self._staging_dir)
if self._dry_run:
logger.info("[DRY RUN] copy %s -> %s", src, dst)
return
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
# ---------------------------------------------------------------------------
# Factory
# ---------------------------------------------------------------------------
def create_storage_backend(
*,
staging_dir: Path | None = None,
dry_run: bool = False,
upload_concurrency: int | None = None,
) -> StorageBackend:
"""Create a storage backend.
Args:
staging_dir: If provided, returns a ``LocalStorageBackend``
that copies files under this directory. Otherwise returns an
``S3StorageBackend``.
dry_run: If ``True``, the backend logs actions without writing.
upload_concurrency: Max concurrent uploads for S3 (default: 10).
Ignored for local backends.
"""
if staging_dir is not None:
return LocalStorageBackend(staging_dir, dry_run=dry_run)
kwargs: dict = {"dry_run": dry_run}
if upload_concurrency is not None:
kwargs["upload_concurrency"] = upload_concurrency
return S3StorageBackend(**kwargs)