#!/usr/bin/env python3 # Copyright Advanced Micro Devices, Inc. # SPDX-License-Identifier: MIT """ Usage: generate_s3_index.py [-h] --run-id RUN_ID [--output-dir OUTPUT_DIR] [--dry-run] Generate index.html for each first-level subdirectory under a CI run prefix. For each subdirectory found under {run_id}-{platform}/: - {subdir}/index.html -- recursive listing of all files under that subdir In CI (no --output-dir): subdirectories are discovered by listing S3 objects under the run prefix. Index files are uploaded to S3. In local mode (--output-dir): subdirectories are discovered by scanning the local staging directory. Index files are written to the same directory tree. AWS credentials are resolved through boto3's default credential chain. """ import argparse import html import os from urllib.parse import quote from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path import platform import sys import tempfile PLATFORM = platform.system().lower() # _therock_utils is a sibling package in the same build_tools/ directory. sys.path.insert(0, str(Path(__file__).resolve().parent)) from _therock_utils.storage_backend import StorageBackend, create_storage_backend from _therock_utils.storage_location import StorageLocation def log(*args): print(*args) sys.stdout.flush() # --------------------------------------------------------------------------- # HTML generation # --------------------------------------------------------------------------- @dataclass class _FileEntry: name: str href: str size_bytes: int # -1 if unknown last_modified: datetime | None def _pretty_size(size_bytes: int) -> str: if size_bytes < 0: return "—" for factor, suffix in [ (1024**5, " PB"), (1024**4, " TB"), (1024**3, " GB"), (1024**2, " MB"), (1024**1, " KB"), (1024**0, " B"), ]: if size_bytes >= factor: return f"{int(size_bytes / factor)}{suffix}" return f"{size_bytes} B" _HTML_STYLE = """\ """ def _generate_index_html( title: str, entries: list[_FileEntry], parent_href: str | None ) -> str: """Generate an HTML index page for a list of file entries.""" lines = [ _HTML_STYLE, "", f"

{html.escape(title)}

", "
", "", "", ] if parent_href: lines.append( f'' f"" ) for entry in entries: size_str = _pretty_size(entry.size_bytes) if entry.last_modified: mod_iso = entry.last_modified.isoformat() mod_str = entry.last_modified.strftime("%Y-%m-%d %H:%M UTC") mod_cell = f'' else: mod_cell = "—" lines.append( f'' f"" ) lines += ["
NameSizeModified
..——
{html.escape(entry.name)}{size_str}{mod_cell}
", ""] return "\n".join(lines) # --------------------------------------------------------------------------- # S3 listing helpers # --------------------------------------------------------------------------- def _list_files_s3(s3_client, bucket: str, dir_prefix: str) -> list[_FileEntry]: """List immediate contents under dir_prefix (non-recursive). Returns subdirectories (from CommonPrefixes) and files (from Contents), excluding index.html itself. """ prefix = f"{dir_prefix}/" paginator = s3_client.get_paginator("list_objects_v2") entries = [] for page in paginator.paginate(Bucket=bucket, Prefix=prefix, Delimiter="/"): for cp in page.get("CommonPrefixes", []): name = cp["Prefix"][len(prefix) :] # e.g. "gfx94X/" entries.append( _FileEntry( name=name, href=name + "index.html", size_bytes=-1, last_modified=None, ) ) for obj in page.get("Contents", []): key = obj["Key"] filename = key[len(prefix) :] # Skip subdirectory entries, empty keys, and index.html itself. if not filename or filename == "index.html" or "/" in filename: continue entries.append( _FileEntry( name=filename, href=filename, size_bytes=obj["Size"], last_modified=obj["LastModified"], ) ) entries.sort(key=lambda e: e.name) return entries def _discover_dirs_with_files_s3(s3_client, bucket: str, run_prefix: str) -> list[str]: """Discover all directories under run_prefix that contain files (any depth).""" prefix = f"{run_prefix}/" paginator = s3_client.get_paginator("list_objects_v2") dirs: set[str] = set() for page in paginator.paginate(Bucket=bucket, Prefix=prefix): for obj in page.get("Contents", []): key = obj["Key"] filename = key[len(prefix) :] if not filename or filename.endswith("/"): continue # The directory containing this file parent = key.rsplit("/", 1)[0] dirs.add(parent) return sorted(dirs) def _expand_with_intermediate_dirs(dirs: list[str], run_prefix: str) -> list[str]: """Add every ancestor directory of each entry, up to and including run_prefix. Without this, intermediate directories that contain only subdirectories (e.g. ``/logs/`` when only ``/logs//`` has files) have no index.html generated, so links from a parent index 404. """ all_dirs: set[str] = set(dirs) all_dirs.add(run_prefix) for d in dirs: while "/" in d and d != run_prefix: d = d.rsplit("/", 1)[0] all_dirs.add(d) if d == run_prefix: break return sorted(all_dirs) # --------------------------------------------------------------------------- # Local listing helpers (for --output-dir mode) # --------------------------------------------------------------------------- def _list_files_local(staging_dir: Path, dir_prefix: str) -> list[_FileEntry]: """List immediate contents in {staging_dir}/{dir_prefix} (non-recursive). Returns subdirectories and files, excluding index.html itself. """ root = staging_dir / dir_prefix if not root.is_dir(): return [] entries = [] for p in sorted(root.iterdir()): if p.is_dir(): entries.append( _FileEntry( name=p.name + "/", href=p.name + "/index.html", size_bytes=-1, last_modified=None, ) ) elif p.is_file() and p.name != "index.html": stat = p.stat() entries.append( _FileEntry( name=p.name, href=p.name, size_bytes=stat.st_size, last_modified=datetime.fromtimestamp( stat.st_mtime, tz=timezone.utc ), ) ) return entries def _discover_dirs_with_files_local(staging_dir: Path, run_prefix: str) -> list[str]: """Discover all directories under {staging_dir}/{run_prefix} that contain files.""" run_dir = staging_dir / run_prefix if not run_dir.is_dir(): return [] dirs: set[str] = set() for p in run_dir.rglob("*"): if p.is_file() and p.name != "index.html": rel_dir = p.parent.relative_to(staging_dir).as_posix() dirs.add(rel_dir) return sorted(dirs) # --------------------------------------------------------------------------- # Index generation and upload # --------------------------------------------------------------------------- def _upload_html( html_content: str, dest: StorageLocation, backend: StorageBackend, dry_run: bool ) -> None: """Write html to a temp file and upload it to dest.""" if dry_run: return with tempfile.NamedTemporaryFile( mode="w", suffix=".html", encoding="utf-8", delete=False ) as tmp: tmp.write(html_content) tmp_path = Path(tmp.name) try: backend.upload_file(tmp_path, dest) finally: tmp_path.unlink(missing_ok=True) def generate_index_for_directory( bucket: str, dir_prefix: str, backend: StorageBackend, *, s3_client=None, staging_dir: Path | None = None, dry_run: bool = False, parent_href: str | None = "../index.html", ) -> None: """Generate and upload index.html listing all files under dir_prefix. Args: bucket: S3 bucket name. dir_prefix: Path prefix relative to the bucket root (e.g., '12345-linux/logs'). backend: Storage backend for uploading the index. s3_client: Boto3 S3 client (required when staging_dir is None). staging_dir: Local staging directory root (used instead of S3 for local testing). dry_run: If True, log what would be uploaded without actually uploading. parent_href: href for the parent directory link, or None to omit it (e.g. when dir_prefix is the run root and has no indexed parent). """ if staging_dir is not None: entries = _list_files_local(staging_dir, dir_prefix) else: entries = _list_files_s3(s3_client, bucket, dir_prefix) title = dir_prefix.rsplit("/", 1)[-1] html_content = _generate_index_html( title=title, entries=entries, parent_href=parent_href ) dest = StorageLocation(bucket=bucket, relative_path=f"{dir_prefix}/index.html") log( f"[INFO] Uploading index ({len(entries)} files) → " f"{dest.s3_uri if staging_dir is None else dest.relative_path}" ) _upload_html(html_content, dest, backend, dry_run) def run(args) -> None: # WorkflowOutputRoot is only needed in the CLI path (to resolve bucket from GHA env). from _therock_utils.workflow_outputs import WorkflowOutputRoot output_root = ( WorkflowOutputRoot.for_local(run_id=args.run_id, platform=PLATFORM) if args.output_dir is not None else WorkflowOutputRoot.from_workflow_run(run_id=args.run_id, platform=PLATFORM) ) backend = create_storage_backend(staging_dir=args.output_dir, dry_run=args.dry_run) prefix = output_root.prefix bucket = args.bucket if args.bucket else output_root.bucket staging_dir = args.output_dir s3_client = None if staging_dir is None: import boto3 s3_client = boto3.client("s3") log(f"[INFO] Discovering directories from S3 prefix: {prefix}/") dirs = _discover_dirs_with_files_s3(s3_client, bucket, prefix) else: log(f"[INFO] Discovering directories from local dir: {staging_dir / prefix}/") dirs = _discover_dirs_with_files_local(staging_dir, prefix) if not dirs: log("[WARN] No directories with files found. Nothing to index.") return if args.include_intermediate_dirs: before = len(dirs) dirs = _expand_with_intermediate_dirs(dirs, prefix) log( f"[INFO] Expanded {before} dirs with files to {len(dirs)} dirs " f"including intermediate ancestors." ) log(f"[INFO] Found directories: {dirs}") for dir_prefix in dirs: log(f"\n[INFO] Generating index for: {dir_prefix}") generate_index_for_directory( bucket=bucket, dir_prefix=dir_prefix, backend=backend, s3_client=s3_client, staging_dir=staging_dir, dry_run=args.dry_run, parent_href=None if dir_prefix == prefix else "../index.html", ) log("\n[INFO] Done.") if __name__ == "__main__": parser = argparse.ArgumentParser( description="Generate S3 index files after uploads" ) parser.add_argument( "--run-id", type=str, required=True, help="GitHub run ID of the workflow run", ) parser.add_argument( "--output-dir", type=Path, default=None, help="Local staging directory instead of S3 (for testing)", ) parser.add_argument( "--bucket", type=str, default=None, help="Override the S3 bucket (default: resolved from GHA environment)", ) parser.add_argument( "--dry-run", action="store_true", help="Print what would be uploaded without actually uploading", ) parser.add_argument( "--include-intermediate-dirs", action="store_true", help=( "Also generate index.html for directories whose contents are " "only subdirectories (no direct files). Without this, links to " "such directories from a parent index 404." ), ) args = parser.parse_args() run(args)