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
1399 lines
48 KiB
Python
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()
|