Files
TheRock/build_tools/artifact_manager.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

1399 lines
48 KiB
Python

#!/usr/bin/env python
# Copyright Advanced Micro Devices, Inc.
# SPDX-License-Identifier: MIT
"""Stage-aware artifact manager for multi-stage CI/CD pipeline.
This CLI tool manages artifacts between build stages, supporting both local
directories (for prototyping) and S3 (for CI/CD). Operations are parallelized
for performance.
Usage:
# Fetch inbound artifacts for a stage (downloads and extracts in parallel)
python stage_artifact_manager.py fetch \
--stage math-libs \
--amdgpu-families gfx94X-dcgpu \
--run-id 12345 \
--output-dir build/
# Fetch and flatten artifacts into single directory structure
python stage_artifact_manager.py fetch \
--stage math-libs \
--amdgpu-families gfx94X-dcgpu \
--run-id 12345 \
--output-dir build/ \
--flatten
# Push produced artifacts after building (compresses and uploads in parallel)
python stage_artifact_manager.py push \
--stage math-libs \
--amdgpu-families gfx94X-dcgpu \
--run-id 12345 \
--build-dir build/
# List what artifacts a stage needs/produces
python stage_artifact_manager.py info \
--stage math-libs \
--amdgpu-families gfx94X-dcgpu
Environment variables:
THEROCK_LOCAL_STAGING_DIR: Use local directory instead of S3
THEROCK_RUN_ID: Override run ID
THEROCK_PLATFORM: Override platform (default: current platform)
"""
import argparse
import concurrent.futures
from dataclasses import dataclass
import os
import platform as platform_module
import shutil
import sys
import threading
import time
from pathlib import Path
from typing import List, Optional, Set
from _therock_utils.archive_util import open_archive_for_read
from _therock_utils.os_util import rmtree_with_retry
from _therock_utils.cmake_amdgpu_targets import (
amdgpu_family_map,
expand_families,
)
from _therock_utils.build_topology import BuildTopology
from _therock_utils.artifact_backend import (
ArtifactBackend,
ARTIFACT_EXTENSIONS,
LocalDirectoryBackend,
S3Backend,
create_backend_from_env,
)
from _therock_utils.artifacts import ArtifactName, ArtifactPopulator
from _therock_utils.hash_util import calculate_hash
from _therock_utils.workflow_outputs import WorkflowOutputRoot
# Component types that artifacts are split into
ARTIFACT_COMPONENTS = ["lib", "run", "dev", "dbg", "doc", "test"]
def _get_base_arch(target: str) -> str:
"""Strip xnack/other suffixes: 'gfx942:xnack+' -> 'gfx942'.
Note: This strips everything after the first colon. Any suffix (not just
xnack) will be removed, e.g., 'gfx942:foo' -> 'gfx942'.
"""
if not target:
return ""
base = target.split(":")[0]
return base if base else target
def _matches_target(artifact_target: str, requested_targets: set[str]) -> bool:
"""Match if the artifact's base arch equals any requested target's base arch.
Uses base-arch matching: both the artifact target and requested targets are
stripped to their base arch before comparison. This means requesting 'gfx942'
will match 'gfx942', 'gfx942:xnack+', 'gfx942:xnack-', or any 'gfx942:*' variant.
Known limitation: All colon-suffixed variants of the same base arch will match.
For example, requesting 'gfx942' matches 'gfx942:xnack+', 'gfx942:xnack-',
'gfx942:abc', etc. This is intentional to ensure all relevant artifacts are
fetched for a given GPU architecture.
"""
if not artifact_target:
return False
requested_bases = {_get_base_arch(t) for t in requested_targets}
return _get_base_arch(artifact_target) in requested_bases
def log(msg: str):
"""Print message and flush."""
print(msg, flush=True)
def _delay_for_retry(seconds: float):
"""Sleep for retry delay. Mockable for testing."""
time.sleep(seconds)
def get_default_topology_path() -> Path:
"""Get the default BUILD_TOPOLOGY.toml path from the repository root."""
script_dir = Path(__file__).parent
repo_root = script_dir.parent
return repo_root / "BUILD_TOPOLOGY.toml"
def get_topology(topology_path: Optional[Path] = None) -> BuildTopology:
"""Load the BUILD_TOPOLOGY.toml.
Args:
topology_path: Path to topology file. If None, uses default location.
"""
if topology_path is None:
topology_path = get_default_topology_path()
if not topology_path.exists():
raise FileNotFoundError(f"BUILD_TOPOLOGY.toml not found at {topology_path}")
return BuildTopology(str(topology_path))
# =============================================================================
# Shared Helpers
# =============================================================================
def parse_target_families(args: argparse.Namespace) -> List[str]:
"""Parse target families from argparse args.
Returns a list starting with "generic", extended with any families and
individual targets from the args.
"""
output_families = ["generic"]
if args.generic_only:
log("Using generic (host) artifacts only")
else:
if args.amdgpu_families:
input_families = args.amdgpu_families.split(";")
output_families.extend(input_families)
if args.expand_family_to_targets:
family_map = amdgpu_family_map()
for target in expand_families(input_families, family_map, strict=False):
if target not in output_families:
output_families.append(target)
if args.amdgpu_targets:
output_families.extend(
t.strip() for t in args.amdgpu_targets.split(",") if t.strip()
)
return output_families
def find_available_artifacts(
artifact_names: set[str],
target_families: list[str],
available: set[str],
excluded_components: set[str] | None = None,
) -> list[str]:
"""Find which artifacts exist in the available set.
Iterates available artifacts and filters by artifact_names, target_families,
and components. Uses base-arch matching: requesting a base arch (e.g., "gfx942")
will also match variants with suffixes (e.g., "gfx942:xnack+", "gfx942:xnack-").
Prefers .tar.zst over .tar.xz when both exist, because zstd offers faster
decompression and better compression ratios for our artifact workloads.
"""
excluded_components = excluded_components or set()
targets_to_match = set(target_families)
# Use structured ArtifactName parsing for reliable matching (like fetch_artifacts.py)
matched = []
seen = set()
# Sort .tar.zst before .tar.xz so dedup prefers zstd (faster decompression)
def _sort_key(f: str) -> tuple:
base = f.rsplit(".tar", 1)[0]
priority = 0 if f.endswith(".tar.zst") else 1
return (base, priority)
for filename in sorted(available, key=_sort_key):
an = ArtifactName.from_filename(filename)
if not an:
continue
if an.name not in artifact_names:
continue
if an.component in excluded_components:
continue
if not _matches_target(an.target_family, targets_to_match):
continue
# Dedupe: prefer .tar.zst over .tar.xz for same artifact
key = (an.name, an.component, an.target_family)
if key in seen:
continue
seen.add(key)
matched.append(filename)
return matched
def parse_excluded_components(raw_components: str) -> set[str]:
"""Parse and validate component names passed to --exclude-components."""
components = {
comp.strip()
for comp in raw_components.replace(";", ",").split(",")
if comp.strip()
}
invalid_components = components - set(ARTIFACT_COMPONENTS)
if invalid_components:
raise ValueError(
"Invalid artifact component(s): "
f"{', '.join(sorted(invalid_components))}. "
f"Valid components are: {', '.join(ARTIFACT_COMPONENTS)}"
)
return components
def parse_excluded_artifacts(
raw_artifacts: str,
valid_artifacts: set[str],
) -> set[str]:
"""Parse and validate artifact names passed to --exclude-artifacts."""
artifacts = {
artifact.strip()
for artifact in raw_artifacts.replace(";", ",").split(",")
if artifact.strip()
}
invalid_artifacts = artifacts - valid_artifacts
if invalid_artifacts:
raise ValueError(
"Invalid artifact name(s): "
f"{', '.join(sorted(invalid_artifacts))}. "
f"Valid artifacts are: {', '.join(sorted(valid_artifacts))}"
)
return artifacts
# =============================================================================
# Fetch (Download + Extract) with Parallel Processing
# =============================================================================
@dataclass
class DownloadRequest:
"""Request to download an artifact."""
artifact_key: str
dest_path: Path
backend: ArtifactBackend
@dataclass
class ExtractRequest:
"""Request to extract a downloaded artifact."""
archive_path: Path
output_dir: Path
delete_archive: bool
flatten: bool
bootstrap: bool = False
# Shared state for parallel bootstrap extraction
cleaned_paths: Optional[set] = None
cleaned_paths_lock: Optional[threading.Lock] = None
def download_artifact(request: DownloadRequest) -> Optional[Path]:
"""Download a single artifact with retry logic.
Skips downloading if the file already exists in the download cache.
"""
# TODO: Validate cached file integrity (e.g. compare size against S3
# object metadata). A partial download from a killed process would pass
# this check. Consider adding a --no-cache flag to bypass.
if request.dest_path.exists():
log(f" == Cached {request.artifact_key}")
return request.dest_path
MAX_RETRIES = 3
BASE_DELAY_SECONDS = 2
for attempt in range(MAX_RETRIES):
try:
log(f" ++ Downloading {request.artifact_key}")
request.dest_path.parent.mkdir(parents=True, exist_ok=True)
request.backend.download_artifact(request.artifact_key, request.dest_path)
return request.dest_path
except FileNotFoundError:
# Artifact doesn't exist - not an error, just skip
return None
except Exception as e:
if attempt < MAX_RETRIES - 1:
delay = BASE_DELAY_SECONDS * (2**attempt)
log(
f" ++ Retry {attempt + 1}/{MAX_RETRIES} for {request.artifact_key}: {e}"
)
_delay_for_retry(delay)
else:
log(f" !! Failed to download {request.artifact_key}: {e}")
return None
return None
class BootstrappingPopulator(ArtifactPopulator):
"""ArtifactPopulator that creates .prebuilt markers for bootstrapping.
When used with parallel extraction, pass shared state (cleaned_paths set
and lock) to avoid race conditions where multiple threads try to
clean/populate the same directory paths.
"""
def __init__(
self,
output_path: Path,
verbose: bool = False,
cleaned_paths: Optional[set] = None,
cleaned_paths_lock: Optional[threading.Lock] = None,
):
super().__init__(output_path=output_path, verbose=verbose, flatten=False)
self.created_markers: List[Path] = []
self._cleaned_paths = cleaned_paths if cleaned_paths is not None else set()
self._lock = (
cleaned_paths_lock if cleaned_paths_lock is not None else threading.Lock()
)
def on_first_relpath(self, relpath: str):
if not relpath:
return # Skip empty relpaths
full_path = self.output_path / relpath
with self._lock:
if relpath in self._cleaned_paths:
return # Already cleaned by another thread
self._cleaned_paths.add(relpath)
# Do cleanup while holding lock to prevent race with extraction
if full_path.exists():
rmtree_with_retry(full_path)
# Write the ".prebuilt" marker file
prebuilt_path = full_path.with_name(full_path.name + ".prebuilt")
prebuilt_path.parent.mkdir(parents=True, exist_ok=True)
prebuilt_path.touch()
self.created_markers.append(prebuilt_path)
def extract_artifact(request: ExtractRequest) -> Optional[Path]:
"""Extract a single artifact archive."""
try:
archive_path = request.archive_path
artifact_name, *_ = archive_path.name.partition(".")
if request.bootstrap:
# Bootstrap mode: use ArtifactPopulator with prebuilt markers
output_dir = request.output_dir
log(f" ++ Bootstrapping {archive_path.name}")
populator = BootstrappingPopulator(
output_path=output_dir,
verbose=False,
cleaned_paths=request.cleaned_paths,
cleaned_paths_lock=request.cleaned_paths_lock,
)
populator(archive_path)
elif request.flatten:
output_dir = request.output_dir
log(f" ++ Flattening {archive_path.name} to {output_dir}")
flattener = ArtifactPopulator(
output_path=output_dir, verbose=False, flatten=True
)
flattener(archive_path)
else:
output_dir = request.output_dir / artifact_name
if output_dir.exists():
rmtree_with_retry(output_dir)
log(f" ++ Extracting {archive_path.name}")
with open_archive_for_read(archive_path) as tf:
tf.extractall(output_dir, filter="tar")
if request.delete_archive:
archive_path.unlink()
return output_dir
except Exception as e:
log(f" !! Failed to extract {request.archive_path.name}: {e}")
return None
def do_fetch(args: argparse.Namespace):
"""Fetch inbound artifacts for a stage with parallel download and extract."""
topology = get_topology(args.topology)
# Determine which artifacts to fetch
if args.stage == "all":
# Fetch all artifacts in the topology
inbound = set(topology.artifacts.keys())
log(f"Fetching all {len(inbound)} artifacts")
else:
# Validate stage
if args.stage not in topology.build_stages:
log(f"ERROR: Stage '{args.stage}' not found")
log(f"Available stages: {', '.join(topology.build_stages.keys())}")
sys.exit(1)
# Get inbound artifacts for this stage
inbound = topology.get_inbound_artifacts(args.stage)
if not inbound:
log(f"Stage '{args.stage}' has no inbound artifacts")
return
log(
f"Stage '{args.stage}' needs {len(inbound)} artifacts: {', '.join(sorted(inbound))}"
)
target_families = parse_target_families(args)
excluded_artifacts = parse_excluded_artifacts(
args.exclude_artifacts,
set(topology.artifacts.keys()),
)
if excluded_artifacts:
log(f"Excluding artifacts: {', '.join(sorted(excluded_artifacts))}")
inbound -= excluded_artifacts
if not inbound:
log("No artifacts remain after exclusions")
return
# Create backend
backend = create_backend_from_env(
run_id=args.run_id,
github_repository=args.run_github_repo,
platform=args.platform,
)
log(f"Using backend: {backend.base_uri}")
# Get list of available artifacts
available = set(backend.list_artifacts())
log(f"Found {len(available)} artifacts in backend")
# Build download requests
output_dir = Path(args.output_dir)
shared_cache = args.download_cache_dir is not None
download_dir = (
args.download_cache_dir if shared_cache else output_dir / ".download_cache"
)
download_dir.mkdir(parents=True, exist_ok=True)
excluded_components = parse_excluded_components(args.exclude_components)
if excluded_components:
log(f"Excluding artifact components: {', '.join(sorted(excluded_components))}")
matched_filenames = find_available_artifacts(
inbound,
target_families,
available,
excluded_components=excluded_components,
)
download_requests = [
DownloadRequest(
artifact_key=filename,
dest_path=download_dir / filename,
backend=backend,
)
for filename in matched_filenames
]
if not download_requests:
log("No matching artifacts found to download")
return
log(f"\nDownloading {len(download_requests)} artifacts...")
# Parallel download and extract pipeline
downloaded_count = 0
extracted_count = 0
with concurrent.futures.ThreadPoolExecutor(
max_workers=args.download_concurrency
) as download_executor:
download_futures = [
download_executor.submit(download_artifact, req)
for req in download_requests
]
if args.no_extract:
# Just wait for downloads
for future in concurrent.futures.as_completed(download_futures):
result = future.result()
if result:
downloaded_count += 1
else:
# Pipeline: extract as downloads complete
# For bootstrap mode, create shared state to coordinate parallel
# extractions that may write to overlapping paths
bootstrap_cleaned_paths: set = set()
bootstrap_lock = threading.Lock()
with concurrent.futures.ThreadPoolExecutor(
max_workers=args.extract_concurrency
) as extract_executor:
extract_futures = []
for download_future in concurrent.futures.as_completed(
download_futures
):
downloaded_path = download_future.result()
if not downloaded_path or not downloaded_path.exists():
continue
downloaded_count += 1
extract_futures.append(
extract_executor.submit(
extract_artifact,
ExtractRequest(
archive_path=downloaded_path,
output_dir=(
output_dir / "artifacts"
if not args.bootstrap and not args.flatten
else output_dir
),
# Don't delete during parallel extraction - cleanup
# happens after all extractions complete
delete_archive=False,
flatten=args.flatten,
bootstrap=args.bootstrap,
cleaned_paths=(
bootstrap_cleaned_paths if args.bootstrap else None
),
cleaned_paths_lock=(
bootstrap_lock if args.bootstrap else None
),
),
)
)
for future in concurrent.futures.as_completed(extract_futures):
result = future.result()
if result:
extracted_count += 1
log(f"\nDownloaded {downloaded_count} artifacts, extracted {extracted_count}")
# Cleanup download cache (skip when using a shared cache dir)
if download_dir.exists() and not args.no_extract and not shared_cache:
rmtree_with_retry(download_dir)
# Fail if any downloads failed
total_requested = len(download_requests)
if downloaded_count < total_requested:
log(
f"ERROR: Only downloaded {downloaded_count}/{total_requested} artifacts - "
f"{total_requested - downloaded_count} failed"
)
sys.exit(1)
# Fail if any extractions failed (when extraction was requested)
if not args.no_extract and extracted_count < downloaded_count:
log(
f"ERROR: Only extracted {extracted_count}/{downloaded_count} artifacts - "
f"{downloaded_count - extracted_count} failed"
)
sys.exit(1)
# =============================================================================
# Push (Compress + Upload) with Parallel Processing
# =============================================================================
@dataclass
class CompressRequest:
"""Request to compress an artifact directory."""
source_dir: Path
archive_path: Path
compression_type: str = "zstd"
compression_level: Optional[int] = (
None # None = use algorithm default (3 for zstd, 6 for xz)
)
@dataclass
class UploadRequest:
"""Request to upload an artifact."""
source_path: Path
artifact_key: str
backend: ArtifactBackend
def _format_bytes(size: Optional[int]) -> str:
return "unknown" if size is None else f"{size} bytes"
def _directory_file_count_and_size_sum(path: Path) -> Optional[tuple[int, int]]:
try:
count = 0
total = 0
for child in path.rglob("*"):
if child.is_file():
count += 1
total += child.stat().st_size
return count, total
except OSError:
return None
def _hash_file_path(archive_path: Path) -> Path:
return Path(f"{archive_path}.sha256sum")
def _read_sha256sum_value(hash_path: Path) -> str:
"""Read the digest from a hash sidecar.
Existing files in tests and logs use both plain digest and
``sha256sum``-style ``digest filename`` formats. The digest is always the
first whitespace-delimited token.
"""
content = hash_path.read_text().strip()
if not content:
raise ValueError(f"Empty sha256sum file: {hash_path}")
return content.split()[0]
def compress_artifact(request: CompressRequest) -> Optional[Path]:
"""Compress a single artifact directory using fileset_tool.py artifact-archive."""
try:
log(f" ++ Compressing {request.source_dir.name}")
request.archive_path.parent.mkdir(parents=True, exist_ok=True)
hash_path = _hash_file_path(request.archive_path)
# Use fileset_tool.py artifact-archive for proper archive creation
import subprocess
script_dir = Path(__file__).parent
fileset_tool = script_dir / "fileset_tool.py"
cmd = [
sys.executable,
str(fileset_tool),
"artifact-archive",
"-o",
str(request.archive_path),
"--compression-type",
request.compression_type,
]
if request.compression_level is not None:
cmd.extend(["--compression-level", str(request.compression_level)])
cmd.extend(
[
"--hash-file",
str(hash_path),
str(request.source_dir),
]
)
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(
f"fileset_tool.py artifact-archive failed (returncode={result.returncode}): {result.stderr}"
)
sha256 = _read_sha256sum_value(hash_path)
file_count_and_size = _directory_file_count_and_size_sum(request.source_dir)
if file_count_and_size is None:
file_count = "unknown"
uncompressed_size = None
else:
file_count, uncompressed_size = file_count_and_size
log(
f" ++ Compressed {request.source_dir.name} "
f"(files={file_count}, "
f"uncompressed_size={_format_bytes(uncompressed_size)}) "
f"-> {request.archive_path.name} "
f"(compressed_size={_format_bytes(request.archive_path.stat().st_size)}, "
f"sha256={sha256})"
)
return request.archive_path
except Exception as e:
log(f" !! Failed to compress {request.source_dir.name}: {e}")
return None
def upload_artifact(request: UploadRequest) -> bool:
"""Upload a single artifact with retry logic."""
MAX_RETRIES = 3
BASE_DELAY_SECONDS = 2
for attempt in range(MAX_RETRIES):
try:
# Upload the artifact itself.
sha_path = _hash_file_path(request.source_path)
sha256 = calculate_hash(request.source_path, "sha256").hexdigest()
compressed_size = request.source_path.stat().st_size
log(
f" ++ Uploading {request.artifact_key} "
f"(compressed_size={_format_bytes(compressed_size)}, "
f"sha256={sha256})"
)
request.backend.upload_artifact(request.source_path, request.artifact_key)
# Upload the artifact's sha256sum file.
if sha_path.exists():
sha256sum_sha256 = _read_sha256sum_value(sha_path)
if sha256sum_sha256 != sha256:
log(
f" !! WARNING: sha256 mismatch for {request.artifact_key}: "
f"computed_sha256={sha256}, sha256sum_sha256={sha256sum_sha256}"
)
log(
f" ++ Uploading {request.artifact_key}.sha256sum "
f"(artifact_sha256={sha256sum_sha256}, "
f"size={_format_bytes(sha_path.stat().st_size)})"
)
request.backend.upload_artifact(
sha_path, f"{request.artifact_key}.sha256sum"
)
return True
except Exception as e:
if attempt < MAX_RETRIES - 1:
delay = BASE_DELAY_SECONDS * (2**attempt)
log(
f" ++ Retry {attempt + 1}/{MAX_RETRIES} for {request.artifact_key}: {e}"
)
_delay_for_retry(delay)
else:
log(f" !! Failed to upload {request.artifact_key}: {e}")
return False
return False
def do_push(args: argparse.Namespace):
"""Push produced artifacts after building with parallel compress and upload."""
topology = get_topology(args.topology)
# Determine which artifacts to push
if args.stage == "all":
produced = set()
stages = topology.get_build_stages()
for stage in stages:
produced.update(topology.get_produced_artifacts(stage.name))
log(
f"All stages produced {len(produced)} artifacts: {', '.join(sorted(produced))}"
)
else:
# Validate stage
if args.stage not in topology.build_stages:
log(f"ERROR: Stage '{args.stage}' not found")
log(f"Available stages: {', '.join(topology.build_stages.keys())}")
sys.exit(1)
# Get produced artifacts for this stage
produced = topology.get_produced_artifacts(args.stage)
if not produced:
log(f"Stage '{args.stage}' produces no artifacts")
return
log(
f"Stage '{args.stage}' produces {len(produced)} artifacts: {', '.join(sorted(produced))}"
)
# Create backend
backend = create_backend_from_env(
run_id=args.run_id,
github_repository=args.run_github_repo,
platform=args.platform,
)
log(f"Using backend: {backend.base_uri}")
# Find artifact directories in build directory
build_dir = Path(args.build_dir)
artifacts_dir = build_dir / "artifacts"
if not artifacts_dir.exists():
log(f"ERROR: Artifacts directory not found: {artifacts_dir}")
sys.exit(1)
# Find artifact directories to compress and upload
# Check for both pre-compressed archives and exploded directories
# Note: We push all artifacts produced by this stage regardless of target family.
# The build system already determined what to build; filtering here is redundant
# and breaks with kpack splitting (which produces individual arch artifacts).
upload_dir = build_dir / ".upload_cache"
upload_dir.mkdir(parents=True, exist_ok=True)
compress_requests = []
direct_upload_requests = []
for item in artifacts_dir.iterdir():
if item.is_dir():
# Exploded artifact directory - needs compression
an = ArtifactName.from_path(item)
if not an:
continue
if an.name not in produced:
continue
ext = ".tar.zst" if args.compression_type == "zstd" else ".tar.xz"
archive_name = f"{item.name}{ext}"
compress_requests.append(
CompressRequest(
source_dir=item,
archive_path=upload_dir / archive_name,
compression_type=args.compression_type,
compression_level=args.compression_level,
)
)
elif (item.suffix == ".xz" and item.name.endswith(".tar.xz")) or (
item.suffix == ".zst" and item.name.endswith(".tar.zst")
):
# Pre-compressed archive - direct upload
an = ArtifactName.from_path(item)
if not an:
continue
if an.name not in produced:
continue
direct_upload_requests.append(
UploadRequest(
source_path=item,
artifact_key=item.name,
backend=backend,
)
)
total_artifacts = len(compress_requests) + len(direct_upload_requests)
if total_artifacts == 0:
log("No matching artifacts found to push")
return
log(
f"\nProcessing {total_artifacts} artifacts ({len(compress_requests)} to compress, {len(direct_upload_requests)} pre-compressed)..."
)
compressed_count = 0
uploaded_count = 0
# Parallel compress and upload pipeline
with concurrent.futures.ThreadPoolExecutor(
max_workers=args.compress_concurrency
) as compress_executor:
compress_futures = [
compress_executor.submit(compress_artifact, req)
for req in compress_requests
]
with concurrent.futures.ThreadPoolExecutor(
max_workers=args.upload_concurrency
) as upload_executor:
upload_futures = []
# Start uploading pre-compressed archives immediately
for req in direct_upload_requests:
upload_futures.append(upload_executor.submit(upload_artifact, req))
# As compression completes, start uploads
for compress_future in concurrent.futures.as_completed(compress_futures):
archive_path = compress_future.result()
if not archive_path or not archive_path.exists():
continue
compressed_count += 1
upload_futures.append(
upload_executor.submit(
upload_artifact,
UploadRequest(
source_path=archive_path,
artifact_key=archive_path.name,
backend=backend,
),
)
)
# Wait for all uploads
for future in concurrent.futures.as_completed(upload_futures):
if future.result():
uploaded_count += 1
log(f"\nCompressed {compressed_count} artifacts, uploaded {uploaded_count}")
# Cleanup upload cache
if upload_dir.exists():
rmtree_with_retry(upload_dir)
# Fail if any artifacts failed to upload
if uploaded_count < total_artifacts:
log(
f"ERROR: Only uploaded {uploaded_count}/{total_artifacts} artifacts - "
f"{total_artifacts - uploaded_count} failed"
)
sys.exit(1)
# =============================================================================
# Copy (Server-side S3 copy between run IDs)
# =============================================================================
@dataclass
class CopyRequest:
"""Request to copy a single artifact between backends."""
artifact_key: str
source_backend: ArtifactBackend
dest_backend: ArtifactBackend
def copy_single_artifact(request: CopyRequest) -> bool:
"""Copy a single artifact with retry logic."""
MAX_RETRIES = 3
BASE_DELAY_SECONDS = 2
for attempt in range(MAX_RETRIES):
try:
log(f" ++ Copying {request.artifact_key}")
request.dest_backend.copy_artifact(
request.artifact_key, request.source_backend
)
return True
except Exception as e:
if attempt < MAX_RETRIES - 1:
delay = BASE_DELAY_SECONDS * (2**attempt)
log(
f" ++ Retry {attempt + 1}/{MAX_RETRIES} for {request.artifact_key}: {e}"
)
_delay_for_retry(delay)
else:
log(f" !! Failed to copy {request.artifact_key}: {e}")
return False
return False
def _create_source_backend(
platform: str,
source_run_id: str,
source_repository: Optional[str],
local_staging_dir: Optional[Path],
) -> ArtifactBackend:
"""Create a backend for the source run ID.
For S3, uses WorkflowOutputRoot.from_workflow_run(lookup_workflow_run=True)
to resolve the correct bucket (which may differ from the current run's bucket).
For local backends, creates a LocalDirectoryBackend in the same staging dir.
"""
if local_staging_dir or os.getenv("THEROCK_LOCAL_STAGING_DIR"):
staging = local_staging_dir or Path(os.environ["THEROCK_LOCAL_STAGING_DIR"])
output_root = WorkflowOutputRoot.for_local(
run_id=source_run_id, platform=platform
)
return LocalDirectoryBackend(
staging_dir=staging,
output_root=output_root,
)
output_root = WorkflowOutputRoot.from_workflow_run(
run_id=source_run_id,
platform=platform,
github_repository=source_repository,
lookup_workflow_run=True,
)
return S3Backend(output_root=output_root)
def do_copy(args: argparse.Namespace):
"""Copy produced artifacts for one or more stages from one run to another."""
topology = get_topology(args.topology)
# Parse and validate stages (comma-separated). Unlike fetch/push which
# operate on a single stage, copy accepts multiple stages at once so that
# a single setup job can copy all prebuilt stages in one invocation.
stage_names = [s.strip() for s in args.stage.split(",") if s.strip()]
available_stages = topology.build_stages.keys()
for stage_name in stage_names:
if stage_name not in available_stages:
log(f"ERROR: Stage '{stage_name}' not found")
log(f"Available stages: {', '.join(available_stages)}")
sys.exit(1)
# Union produced artifacts across all specified stages
produced: Set[str] = set()
for stage_name in stage_names:
stage_produced = topology.get_produced_artifacts(stage_name)
log(
f"Stage '{stage_name}' produces {len(stage_produced)} artifacts: {', '.join(sorted(stage_produced))}"
)
produced.update(stage_produced)
if not produced:
log("Specified stages produce no artifacts")
return
target_families = parse_target_families(args)
# Create source and dest backends
source_backend = _create_source_backend(
platform=args.platform,
source_run_id=args.source_run_id,
source_repository=args.source_repository,
local_staging_dir=args.local_staging_dir,
)
dest_backend = create_backend_from_env(
run_id=args.run_id,
platform=args.platform,
)
log(f"Source: {source_backend.base_uri}")
log(f"Dest: {dest_backend.base_uri}")
# List available artifacts in source
available = set(source_backend.list_artifacts())
log(f"Found {len(available)} artifacts in source")
# Build copy requests from matched artifacts
matched_filenames = find_available_artifacts(produced, target_families, available)
copy_requests = [
CopyRequest(
artifact_key=filename,
source_backend=source_backend,
dest_backend=dest_backend,
)
for filename in matched_filenames
]
if not copy_requests:
log("No matching artifacts found to copy")
return
if args.dry_run:
log(f"\nDry run: would copy {len(copy_requests)} artifacts:")
for req in copy_requests:
log(f" {req.artifact_key}")
return
log(f"\nCopying {len(copy_requests)} artifacts...")
failed_artifacts = []
with concurrent.futures.ThreadPoolExecutor(
max_workers=args.concurrency
) as executor:
futures = {
executor.submit(copy_single_artifact, req): req for req in copy_requests
}
for future in concurrent.futures.as_completed(futures):
if not future.result():
failed_artifacts.append(futures[future].artifact_key)
copied_count = len(copy_requests) - len(failed_artifacts)
log(f"\nCopied {copied_count}/{len(copy_requests)} artifacts")
if failed_artifacts:
log(f"ERROR: {len(failed_artifacts)} artifacts failed to copy:")
for name in sorted(failed_artifacts):
log(f" - {name}")
sys.exit(1)
# =============================================================================
# Info Commands
# =============================================================================
def do_info(args: argparse.Namespace):
"""Show information about stage artifact requirements."""
topology = get_topology(args.topology)
stage = topology.build_stages.get(args.stage)
if not stage:
log(f"ERROR: Stage '{args.stage}' not found")
log(f"Available stages: {', '.join(topology.build_stages.keys())}")
sys.exit(1)
log(f"Stage: {stage.name}")
log(f"Type: {stage.type}")
log(f"Description: {stage.description}")
log(f"Artifact groups: {', '.join(stage.artifact_groups)}")
# Inbound artifacts
inbound = topology.get_inbound_artifacts(args.stage)
log(f"\nInbound artifacts ({len(inbound)}):")
for name in sorted(inbound):
artifact = topology.artifacts.get(name)
if artifact:
log(f" - {name} ({artifact.type})")
else:
log(f" - {name}")
# Produced artifacts
produced = topology.get_produced_artifacts(args.stage)
log(f"\nProduced artifacts ({len(produced)}):")
for name in sorted(produced):
artifact = topology.artifacts.get(name)
if artifact:
log(f" - {name} ({artifact.type})")
else:
log(f" - {name}")
# Show target families if provided
if args.amdgpu_families:
families = args.amdgpu_families.split(";")
target_families = ["generic"] + families
log(f"\nTarget families: {', '.join(target_families)}")
def do_list_stages(args: argparse.Namespace):
"""List all build stages."""
topology = get_topology(args.topology)
log("Build stages:")
for stage in topology.get_build_stages():
inbound = topology.get_inbound_artifacts(stage.name)
produced = topology.get_produced_artifacts(stage.name)
log(f"\n {stage.name} ({stage.type})")
log(f" {stage.description}")
log(f" Groups: {', '.join(stage.artifact_groups)}")
log(f" Inbound: {len(inbound)} artifacts")
log(f" Produces: {len(produced)} artifacts")
# =============================================================================
# Main
# =============================================================================
def _add_common_args(parser: argparse.ArgumentParser):
"""Add common arguments shared by all subcommands."""
parser.add_argument(
"--topology",
type=Path,
default=None,
help="Path to BUILD_TOPOLOGY.toml (default: auto-detect from repo root)",
)
def _add_target_args(parser: argparse.ArgumentParser):
"""Add target family/GPU arguments to a subparser."""
target_group = parser.add_mutually_exclusive_group()
target_group.add_argument(
"--amdgpu-families",
type=str,
help="Semicolon-separated GPU families (e.g., gfx94X-dcgpu;gfx1100)",
)
target_group.add_argument(
"--generic-only",
action="store_true",
help="Only use generic (host) artifacts, skip device-specific artifacts",
)
parser.add_argument(
"--amdgpu-targets",
type=str,
default="",
help="Comma-separated individual GPU targets for split artifacts (e.g. 'gfx942')",
)
parser.add_argument(
"--expand-family-to-targets",
action="store_true",
help=(
"Expand each --amdgpu-families entry to its constituent gfx targets "
"using cmake/therock_amdgpu_targets.cmake. Use when fetching kpack-split "
"artifacts that are named per target rather than per family. "
"Safe to pass against non-kpack-split buckets: the family name is still "
"matched and the extra per-target lookups simply find nothing."
),
)
def _add_backend_args(parser: argparse.ArgumentParser):
"""Add common backend-related arguments to a subparser."""
_add_common_args(parser)
parser.add_argument(
"--run-id",
type=str,
default=os.getenv("THEROCK_RUN_ID", os.getenv("GITHUB_RUN_ID", "local")),
help="Run ID for artifact storage (default: from env or 'local')",
)
parser.add_argument(
"--platform",
type=str,
default=os.getenv("THEROCK_PLATFORM", platform_module.system().lower()),
help="Platform name (default: current platform)",
)
parser.add_argument(
"--run-github-repo",
type=str,
default=None,
help="GitHub repository for --run-id in 'owner/repo' format (e.g. 'ROCm/TheRock'). "
"Defaults to GITHUB_REPOSITORY env var or 'ROCm/TheRock'",
)
parser.add_argument(
"--local-staging-dir",
type=Path,
default=os.getenv("THEROCK_LOCAL_STAGING_DIR"),
help="Local staging directory (sets THEROCK_LOCAL_STAGING_DIR)",
)
def main(argv: Optional[List[str]] = None):
parser = argparse.ArgumentParser(
description="Stage-aware artifact manager for multi-stage CI/CD pipeline",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
subparsers = parser.add_subparsers(dest="command", required=True)
# fetch command
fetch_parser = subparsers.add_parser(
"fetch", help="Fetch inbound artifacts for a stage"
)
_add_backend_args(fetch_parser)
fetch_parser.add_argument(
"--stage",
type=str,
default="all",
help="Build stage name (default: 'all' fetches all artifacts)",
)
_add_target_args(fetch_parser)
fetch_parser.add_argument(
"--output-dir",
type=Path,
required=True,
help="Output directory for fetched artifacts",
)
fetch_extract_group = fetch_parser.add_mutually_exclusive_group()
fetch_extract_group.add_argument(
"--bootstrap",
action="store_true",
help="Bootstrap build directory (flatten artifacts and create prebuilt markers)",
)
fetch_extract_group.add_argument(
"--flatten",
action="store_true",
help="Flatten artifacts into a single directory structure (merge all artifacts)",
)
fetch_parser.add_argument(
"--no-extract",
action="store_true",
help="Download only, do not extract",
)
fetch_parser.add_argument(
"--download-cache-dir",
type=Path,
default=None,
help="Shared download cache directory. When set, downloaded archives "
"are kept after extraction so subsequent fetches can reuse them. "
"Defaults to OUTPUT_DIR/.download_cache (cleaned up after extraction).",
)
fetch_parser.add_argument(
"--download-concurrency",
type=int,
default=10,
help="Number of concurrent downloads (default: 10)",
)
fetch_parser.add_argument(
"--extract-concurrency",
type=int,
default=None,
help="Number of concurrent extractions (default: auto)",
)
fetch_parser.add_argument(
"--exclude-components",
type=str,
default="",
help="Comma- or semicolon-separated artifact components to exclude "
f"when fetching. Valid components: {', '.join(ARTIFACT_COMPONENTS)}",
)
fetch_parser.add_argument(
"--exclude-artifacts",
type=str,
default="",
help="Comma- or semicolon-separated artifact names to exclude when fetching",
)
fetch_parser.set_defaults(func=do_fetch)
# push command
push_parser = subparsers.add_parser(
"push", help="Push produced artifacts after building"
)
_add_backend_args(push_parser)
push_parser.add_argument(
"--stage", type=str, required=True, help="Build stage name"
)
push_parser.add_argument(
"--build-dir",
type=Path,
required=True,
help="Build directory containing artifacts/",
)
push_parser.add_argument(
"--compression-type",
type=str,
default="zstd",
choices=["zstd", "xz"],
help="Compression algorithm (default: zstd)",
)
push_parser.add_argument(
"--compression-level",
type=int,
default=None,
help="Compression level (default: 3 for zstd, 6 for xz)",
)
push_parser.add_argument(
"--compress-concurrency",
type=int,
default=None,
help="Number of concurrent compressions (default: auto)",
)
push_parser.add_argument(
"--upload-concurrency",
type=int,
default=10,
help="Number of concurrent uploads (default: 10)",
)
push_parser.set_defaults(func=do_push)
# copy command
copy_parser = subparsers.add_parser(
"copy",
help="Copy produced artifacts for a stage from one run to another",
)
_add_backend_args(copy_parser)
copy_parser.add_argument(
"--source-run-id",
type=str,
required=True,
help="Run ID to copy artifacts from (bucket resolved via GitHub API)",
)
copy_parser.add_argument(
"--source-repository",
type=str,
default=os.environ.get("GITHUB_REPOSITORY", "ROCm/TheRock"),
help="GitHub repository for source-run-id in 'owner/repo' format "
"(default: GITHUB_REPOSITORY or 'ROCm/TheRock').",
)
copy_parser.add_argument(
"--stage",
type=str,
required=True,
help="Build stage name(s), comma-separated (e.g., 'compiler-runtime,runtime-tests,math-libs')",
)
_add_target_args(copy_parser)
copy_parser.add_argument(
"--concurrency",
type=int,
default=10,
help="Number of concurrent copy operations (default: 10)",
)
copy_parser.add_argument(
"--dry-run",
action="store_true",
help="List what would be copied without actually copying",
)
copy_parser.set_defaults(func=do_copy)
# info command
info_parser = subparsers.add_parser(
"info", help="Show information about stage artifact requirements"
)
_add_common_args(info_parser)
info_parser.add_argument(
"--stage", type=str, required=True, help="Build stage name"
)
info_parser.add_argument(
"--amdgpu-families",
type=str,
help="Semicolon-separated GPU families to show file lists for",
)
info_parser.set_defaults(func=do_info)
# list-stages command
list_parser = subparsers.add_parser("list-stages", help="List all build stages")
_add_common_args(list_parser)
list_parser.set_defaults(func=do_list_stages)
args = parser.parse_args(argv)
# Set environment variable if --local-staging-dir provided (only on fetch/push)
local_staging_dir = getattr(args, "local_staging_dir", None)
if local_staging_dir:
os.environ["THEROCK_LOCAL_STAGING_DIR"] = str(local_staging_dir)
args.func(args)
if __name__ == "__main__":
main()