Files
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

369 lines
13 KiB
Python

# Copyright Advanced Micro Devices, Inc.
# SPDX-License-Identifier: MIT
"""Abstraction layer for artifact storage backends (S3 or local directory).
This module provides a unified interface for artifact storage that works with
both local directories (for prototyping/testing) and S3 (for CI/CD).
TODO(scotttodd): Consolidate with StorageBackend in storage_backend.py? Both
modules manage S3 clients and local directory mirroring. ArtifactBackend has
download/list/exists operations that StorageBackend doesn't have yet.
Environment-based switching:
- THEROCK_LOCAL_STAGING_DIR set → use LocalDirectoryBackend
- Otherwise → use S3Backend
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional, Set
import os
import shutil
from .workflow_outputs import WorkflowOutputRoot
@dataclass
class ArtifactLocation:
"""Represents an artifact's location in the backend."""
artifact_key: str # e.g., "blas_lib_gfx94X.tar.zst" or "blas_lib_gfx94X.tar.xz"
full_path: str # Backend-specific full path/URI
# Supported artifact archive extensions (in order of preference)
ARTIFACT_EXTENSIONS = (".tar.zst", ".tar.xz")
def _is_artifact_archive(filename: str) -> bool:
"""Check if a filename is a recognized artifact archive."""
return any(filename.endswith(ext) for ext in ARTIFACT_EXTENSIONS)
class ArtifactBackend(ABC):
"""Abstract base for artifact storage backends."""
@abstractmethod
def list_artifacts(self, name_filter: Optional[str] = None) -> List[str]:
"""List available artifact filenames.
Args:
name_filter: Optional artifact name prefix to filter by (e.g., "blas" to match "blas_lib_*")
Returns:
List of artifact filenames (e.g., ["blas_lib_gfx94X.tar.zst", "blas_dev_gfx94X.tar.xz"])
"""
pass
@abstractmethod
def download_artifact(self, artifact_key: str, dest_path: Path) -> None:
"""Download/copy an artifact to a local path.
Args:
artifact_key: The artifact filename (e.g., "blas_lib_gfx94X.tar.xz")
dest_path: Local path to write the artifact to
"""
pass
@abstractmethod
def upload_artifact(self, source_path: Path, artifact_key: str) -> None:
"""Upload/copy a local artifact to the backend.
Args:
source_path: Local path of the artifact to upload
artifact_key: The artifact filename to use in the backend
"""
pass
@abstractmethod
def artifact_exists(self, artifact_key: str) -> bool:
"""Check if an artifact exists in the backend."""
pass
@abstractmethod
def copy_artifact(
self, artifact_key: str, source_backend: "ArtifactBackend"
) -> None:
"""Copy an artifact from source_backend into this backend (server-side when possible).
Also copies the companion .sha256sum file if it exists in the source.
Args:
artifact_key: The artifact filename (e.g., "blas_lib_gfx94X.tar.zst")
source_backend: The backend to copy from
"""
pass
@property
@abstractmethod
def base_uri(self) -> str:
"""Return the base URI/path for this backend."""
pass
class LocalDirectoryBackend(ArtifactBackend):
"""Backend using a local directory (for testing/prototyping).
Directory structure mirrors S3 layout via WorkflowOutputRoot::
{staging_dir}/{output_root.prefix}/
{artifact_name}_{component}_{target_family}.tar.zst
"""
def __init__(self, staging_dir: Path, output_root: WorkflowOutputRoot):
self.staging_dir = Path(staging_dir)
self.output_root = output_root
self.base_path.mkdir(parents=True, exist_ok=True)
@property
def base_path(self) -> Path:
"""Local artifacts directory path."""
return self.staging_dir / self.output_root.prefix
@property
def base_uri(self) -> str:
return str(self.base_path)
def _artifact_path(self, artifact_key: str) -> Path:
"""Get local path for an artifact file."""
return self.output_root.artifact(artifact_key).local_path(self.staging_dir)
def list_artifacts(self, name_filter: Optional[str] = None) -> List[str]:
"""List artifacts in local staging directory."""
artifacts = []
if not self.base_path.exists():
return artifacts
for p in self.base_path.iterdir():
filename = p.name
# Skip non-artifact files (also excludes .sha256sum files)
if not _is_artifact_archive(filename):
continue
# Apply name filter if provided
if name_filter is not None and not filename.startswith(f"{name_filter}_"):
continue
artifacts.append(filename)
return sorted(artifacts)
def download_artifact(self, artifact_key: str, dest_path: Path) -> None:
"""Copy artifact from staging to destination."""
src = self._artifact_path(artifact_key)
if not src.exists():
raise FileNotFoundError(f"Artifact not found in local staging: {src}")
dest_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest_path)
# Also copy sha256sum if it exists
sha_src = self._artifact_path(f"{artifact_key}.sha256sum")
if sha_src.exists():
shutil.copy2(sha_src, dest_path.parent / f"{artifact_key}.sha256sum")
def upload_artifact(self, source_path: Path, artifact_key: str) -> None:
"""Copy artifact from source to staging."""
if not source_path.exists():
raise FileNotFoundError(f"Source artifact not found: {source_path}")
dest = self._artifact_path(artifact_key)
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source_path, dest)
# Also copy sha256sum if it exists
sha_src = source_path.parent / f"{source_path.name}.sha256sum"
if sha_src.exists():
shutil.copy2(sha_src, self._artifact_path(f"{artifact_key}.sha256sum"))
def copy_artifact(
self, artifact_key: str, source_backend: "ArtifactBackend"
) -> None:
"""Copy artifact from another local backend."""
if not isinstance(source_backend, LocalDirectoryBackend):
raise TypeError(
f"Cannot copy from {type(source_backend).__name__} to LocalDirectoryBackend"
)
src = source_backend.base_path / artifact_key
if not src.exists():
raise FileNotFoundError(f"Artifact not found in source backend: {src}")
dest = self.base_path / artifact_key
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest)
# Also copy sha256sum if it exists
sha_src = source_backend.base_path / f"{artifact_key}.sha256sum"
if sha_src.exists():
shutil.copy2(sha_src, self.base_path / f"{artifact_key}.sha256sum")
def artifact_exists(self, artifact_key: str) -> bool:
"""Check if artifact exists in local staging."""
return self._artifact_path(artifact_key).exists()
class S3Backend(ArtifactBackend):
"""Backend using AWS S3.
S3 path structure is defined by WorkflowOutputRoot::
s3://{bucket}/{prefix}/
{artifact_name}_{component}_{target_family}.tar.zst
"""
def __init__(self, output_root: WorkflowOutputRoot):
self.output_root = output_root
self._s3_client = None
@property
def bucket(self) -> str:
return self.output_root.bucket
@property
def s3_prefix(self) -> str:
return self.output_root.prefix
@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``)
When no credentials are found at all, the client falls back to
unsigned requests for public bucket reads.
"""
if self._s3_client is None:
import boto3
from botocore import UNSIGNED
from botocore.config import Config
session = boto3.Session()
credentials = session.get_credentials()
if credentials is not None:
self._s3_client = session.client(
"s3",
verify=True,
config=Config(max_pool_connections=100),
)
else:
self._s3_client = session.client(
"s3",
verify=True,
config=Config(max_pool_connections=100, signature_version=UNSIGNED),
)
return self._s3_client
@property
def base_uri(self) -> str:
return self.output_root.root().s3_uri
def list_artifacts(self, name_filter: Optional[str] = None) -> List[str]:
"""List S3 artifacts."""
paginator = self.s3_client.get_paginator("list_objects_v2")
page_iterator = paginator.paginate(Bucket=self.bucket, Prefix=self.s3_prefix)
artifacts = []
for page in page_iterator:
if "Contents" not in page:
continue
for obj in page["Contents"]:
key = obj["Key"]
# Extract filename from full key
if "/" in key:
filename = key.split("/")[-1]
else:
filename = key
# Skip non-artifact files (also excludes .sha256sum files)
if not _is_artifact_archive(filename):
continue
# Apply name filter if provided
if name_filter is not None and not filename.startswith(
f"{name_filter}_"
):
continue
artifacts.append(filename)
return sorted(set(artifacts))
def download_artifact(self, artifact_key: str, dest_path: Path) -> None:
"""Download from S3."""
loc = self.output_root.artifact(artifact_key)
dest_path.parent.mkdir(parents=True, exist_ok=True)
self.s3_client.download_file(self.bucket, loc.relative_path, str(dest_path))
def upload_artifact(self, source_path: Path, artifact_key: str) -> None:
"""Upload to S3."""
loc = self.output_root.artifact(artifact_key)
self.s3_client.upload_file(str(source_path), self.bucket, loc.relative_path)
def copy_artifact(
self, artifact_key: str, source_backend: "ArtifactBackend"
) -> None:
"""Server-side copy from another S3 backend (cross-bucket supported)."""
if not isinstance(source_backend, S3Backend):
raise TypeError(
f"Cannot copy from {type(source_backend).__name__} to S3Backend"
)
copy_source = {
"Bucket": source_backend.bucket,
"Key": f"{source_backend.s3_prefix}/{artifact_key}",
}
dest_key = f"{self.s3_prefix}/{artifact_key}"
self.s3_client.copy(copy_source, self.bucket, dest_key)
# Also copy sha256sum if it exists
sha_key = f"{artifact_key}.sha256sum"
if source_backend.artifact_exists(sha_key):
sha_copy_source = {
"Bucket": source_backend.bucket,
"Key": f"{source_backend.s3_prefix}/{sha_key}",
}
self.s3_client.copy(
sha_copy_source, self.bucket, f"{self.s3_prefix}/{sha_key}"
)
def artifact_exists(self, artifact_key: str) -> bool:
"""Check if artifact exists in S3."""
try:
loc = self.output_root.artifact(artifact_key)
self.s3_client.head_object(Bucket=self.bucket, Key=loc.relative_path)
return True
except Exception:
return False
def create_backend_from_env(
run_id: Optional[str] = None,
github_repository: Optional[str] = None,
platform: Optional[str] = None,
) -> ArtifactBackend:
"""Create the appropriate backend based on environment variables.
Environment variables:
- THEROCK_LOCAL_STAGING_DIR: If set, use local backend
- THEROCK_RUN_ID: Override run ID (default: "local" or GITHUB_RUN_ID)
- THEROCK_PLATFORM: Override platform (default: current platform)
For S3 backend (when THEROCK_LOCAL_STAGING_DIR is not set):
- Uses WorkflowOutputRoot.from_workflow_run() for bucket selection
"""
import platform as platform_module
local_staging = os.getenv("THEROCK_LOCAL_STAGING_DIR")
platform_name = platform or os.getenv(
"THEROCK_PLATFORM", platform_module.system().lower()
)
run_id = run_id or os.getenv("THEROCK_RUN_ID", os.getenv("GITHUB_RUN_ID", "local"))
if local_staging:
output_root = WorkflowOutputRoot.for_local(
run_id=run_id, platform=platform_name
)
return LocalDirectoryBackend(
staging_dir=Path(local_staging),
output_root=output_root,
)
output_root = WorkflowOutputRoot.from_workflow_run(
run_id=run_id, platform=platform_name, github_repository=github_repository
)
return S3Backend(output_root=output_root)