blob: bfe5be8a595fa9f243f0e5a1b2b26a262282b85c [file] [edit]
#!/usr/bin/env python3
#
# Copyright 2026 The Bazel Authors. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Finds and mirrors external repository URLs to mirror.bazel.build.
Uses `bazel mod show_repo --all_repos --output=streamed_jsonproto` to inspect
all transitive repository definitions, plus any lockfiles referenced by
`repo_cache_tar` rules (such as `//src/test/tools/bzlmod:MODULE.bazel.lock`).
"""
import argparse
from concurrent import futures
import dataclasses
import hashlib
import json
import os
import pathlib
import re
import subprocess
import sys
import tempfile
import time
from typing import Any
import urllib.error
import urllib.parse
import urllib.request
MIRROR_HOST = "mirror.bazel.build"
MIRROR_BASE = f"https://{MIRROR_HOST}"
DEFAULT_GCS_BUCKET = "bazel-mirror"
USER_AGENT = "Bazel-Mirror/1.0"
IGNORED_MIRROR_HOSTS = frozenset({MIRROR_HOST, "bcr.cloudflaremirrors.com"})
_TRANSIENT_HTTP_CODES = frozenset({408, 429, 500, 502, 503, 504})
_DOWNLOADER_REWRITE_RE = re.compile(
r"^\s*rewrite\s+\(?([a-zA-Z0-9._-]+)\)?/.*https://mirror\.bazel\.build/",
re.MULTILINE,
)
@dataclasses.dataclass(frozen=True)
class RepoUrlRef:
"""Tracks a repository that references a candidate URL."""
repo_name: str
explicit_mirrors: tuple[str, ...] = ()
def urlopen_with_retry(
req: urllib.request.Request | str,
timeout: int = 10,
retries: int = 3,
) -> Any:
"""Opens a URL with exponential backoff on transient HTTP/network errors."""
for attempt in range(retries):
try:
return urllib.request.urlopen(req, timeout=timeout)
except urllib.error.HTTPError as e:
if e.code not in _TRANSIENT_HTTP_CODES or attempt == retries - 1:
raise
except (urllib.error.URLError, TimeoutError, OSError):
if attempt == retries - 1:
raise
time.sleep(1 << attempt)
def parse_downloader_cfg_domains(workspace: pathlib.Path) -> set[str]:
"""Extracts mirrored domains from bazel_downloader.cfg in the workspace."""
cfg_path = workspace / "bazel_downloader.cfg"
if not cfg_path.is_file():
return set()
text = cfg_path.read_text(encoding="utf-8")
return set(_DOWNLOADER_REWRITE_RE.findall(text))
def run_show_repo(
workspace: pathlib.Path, bazel_bin: str
) -> list[dict[str, Any]]:
"""Runs `bazel mod show_repo --all_repos` and parses JSON proto lines."""
cmd = [
bazel_bin,
"mod",
"show_repo",
"--all_repos",
"--output=streamed_jsonproto",
]
proc = subprocess.run(
cmd,
cwd=workspace,
capture_output=True,
text=True,
check=True,
)
return [
json.loads(line)
for line in proc.stdout.splitlines()
if line.strip().startswith("{")
]
def get_explicit_attrs(repo: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""Returns explicitly specified attributes keyed by attribute name."""
return {
a["name"]: a
for a in repo.get("attribute", [])
if a.get("explicitlySpecified")
}
def fetch_archive_url(source_url: str) -> tuple[str | None, str | None]:
"""Fetches a BCR source.json and returns (archive_url, error_message)."""
req = urllib.request.Request(source_url, headers={"User-Agent": USER_AGENT})
try:
with urlopen_with_retry(req, timeout=10) as resp:
data = json.load(resp)
except (
urllib.error.URLError,
TimeoutError,
OSError,
json.JSONDecodeError,
) as e:
return None, f"{source_url}: {e}"
if not isinstance(data, dict):
return None, f"{source_url}: expected JSON object"
# Non-archive repos (e.g. git_repository) legitimately omit "url".
url = data.get("url")
return (url if isinstance(url, str) else None), None
def extract_lockfile_source_urls(
workspace: pathlib.Path, lockfile_labels: set[str]
) -> tuple[set[str], list[str]]:
"""Fetches archive URLs from lockfiles referenced by repo_cache_tar rules."""
source_json_urls: set[str] = set()
errors: list[str] = []
for label in sorted(lockfile_labels):
rel = label.removeprefix("@@").removeprefix("@").removeprefix("//")
rel_path = pathlib.Path(rel.replace(":", "/").lstrip("/"))
lock_path = workspace / rel_path
if not lock_path.is_file():
msg = f"Lockfile not found: {lock_path}"
print(f"WARNING: {msg}", file=sys.stderr)
errors.append(msg)
continue
try:
lock_data = json.loads(lock_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as e:
msg = f"Failed to read lockfile {lock_path}: {e}"
print(f"WARNING: {msg}", file=sys.stderr)
errors.append(msg)
continue
for url in lock_data.get("registryFileHashes", {}):
if url.endswith("/source.json"):
source_json_urls.add(url)
archive_urls: set[str] = set()
if source_json_urls:
with futures.ThreadPoolExecutor(max_workers=16) as ex:
for url, err in ex.map(fetch_archive_url, sorted(source_json_urls)):
if err:
print(
f"WARNING: Failed to fetch source.json {err}",
file=sys.stderr,
)
errors.append(err)
elif url:
archive_urls.add(url)
return archive_urls, errors
def collect_candidates(
workspace: pathlib.Path,
bazel_bin: str,
domains: set[str],
include_explicit_mirror_repos: bool,
) -> tuple[dict[str, list[RepoUrlRef]], set[str], list[str]]:
"""Collects candidate URLs to mirror from `show_repo` and lockfiles."""
repos = run_show_repo(workspace, bazel_bin)
candidates: dict[str, list[RepoUrlRef]] = {}
explicit_mirrors: set[str] = set()
lockfile_labels: set[str] = set()
for repo in repos:
cname = repo.get("canonicalName", "")
rule_name = repo.get("repoRuleName", "")
attrs = get_explicit_attrs(repo)
if rule_name == "repo_cache_tar" and "lockfiles" in attrs:
lockfile_labels.update(attrs["lockfiles"].get("stringListValue", []))
urls: list[str] = []
if "url" in attrs and attrs["url"].get("stringValue"):
urls.append(attrs["url"]["stringValue"])
if "urls" in attrs and attrs["urls"].get("stringListValue"):
urls.extend(attrs["urls"]["stringListValue"])
if not urls:
continue
repo_explicit_mirrors = tuple(
u for u in urls if urllib.parse.urlparse(u).netloc == MIRROR_HOST
)
explicit_mirrors.update(repo_explicit_mirrors)
for u in urls:
if "{}" in u:
continue
host = urllib.parse.urlparse(u).netloc
if host in IGNORED_MIRROR_HOSTS:
continue
if host in domains or (
include_explicit_mirror_repos and repo_explicit_mirrors
):
ref = RepoUrlRef(
repo_name=cname, explicit_mirrors=repo_explicit_mirrors
)
candidates.setdefault(u, []).append(ref)
archive_urls, errors = extract_lockfile_source_urls(
workspace, lockfile_labels
)
for u in archive_urls:
if urllib.parse.urlparse(u).netloc in domains:
ref = RepoUrlRef(repo_name="repo_cache_tar(lockfiles)")
candidates.setdefault(u, []).append(ref)
return candidates, explicit_mirrors, errors
def to_mirror_url(source_url: str) -> str:
"""Converts a source URL to its corresponding mirror.bazel.build URL."""
parsed = urllib.parse.urlparse(source_url)
return f"{MIRROR_BASE}/{parsed.netloc}{parsed.path}"
def to_gcs_url(source_url: str, bucket: str) -> str:
"""Converts a source URL to its corresponding gs:// mirror bucket URL."""
parsed = urllib.parse.urlparse(source_url)
return f"gs://{bucket}/{parsed.netloc}{parsed.path}"
def head_status(url: str) -> tuple[str, int | str]:
"""Performs an HTTP HEAD request and returns (url, status_code_or_error)."""
req = urllib.request.Request(
url, method="HEAD", headers={"User-Agent": USER_AGENT}
)
try:
with urlopen_with_retry(req, timeout=10) as resp:
return url, resp.status
except urllib.error.HTTPError as e:
return url, e.code
except (urllib.error.URLError, TimeoutError, OSError) as e:
return url, str(e)
def find_missing_urls(
candidates: dict[str, list[RepoUrlRef]], explicit_mirrors: set[str]
) -> list[str]:
"""Checks mirror.bazel.build via HEAD requests and returns missing URLs."""
urls_to_check = set(explicit_mirrors) | {to_mirror_url(u) for u in candidates}
with futures.ThreadPoolExecutor(max_workers=32) as ex:
status_map = dict(ex.map(head_status, sorted(urls_to_check)))
for url, status in sorted(status_map.items()):
if status not in (200, 404):
print(
f"WARNING: Unexpected response from {url}: {status}",
file=sys.stderr,
)
missing_urls: list[str] = []
for u in sorted(candidates):
if status_map.get(to_mirror_url(u)) == 200:
continue
refs = candidates[u]
# Skip if every repo referencing this URL already has a working explicit
# mirror.bazel.build URL.
if all(
any(status_map.get(m) == 200 for m in ref.explicit_mirrors)
for ref in refs
):
continue
missing_urls.append(u)
return missing_urls
def upload_to_gcs(source_url: str, bucket: str) -> tuple[str, str, str]:
"""Downloads source_url and uploads it to gs://<bucket>/<host>/<path>."""
gcs_url = to_gcs_url(source_url, bucket)
stat_proc = subprocess.run(
["gcloud", "storage", "ls", gcs_url],
capture_output=True,
text=True,
check=False,
)
if stat_proc.returncode == 0:
print(f"SKIPPED: {gcs_url} (already exists in GCS)")
return "SKIPPED", gcs_url, "Artifact already exists"
temp_filename = None
try:
req = urllib.request.Request(source_url, headers={"User-Agent": USER_AGENT})
with (
urlopen_with_retry(req, timeout=300) as resp,
tempfile.NamedTemporaryFile(delete=False) as temp_file,
):
temp_filename = temp_file.name
hasher = hashlib.sha256()
while chunk := resp.read(65536):
temp_file.write(chunk)
hasher.update(chunk)
digest = hasher.hexdigest()
subprocess.run(
[
"gcloud",
"storage",
"cp",
"--no-clobber",
"--cache-control=public, max-age=31536000",
temp_filename,
gcs_url,
],
capture_output=True,
text=True,
check=True,
)
print(f"SUCCESS: {source_url} -> {gcs_url} (sha256: {digest})")
return "SUCCESS", gcs_url, ""
except (
urllib.error.URLError,
TimeoutError,
OSError,
subprocess.CalledProcessError,
) as e:
if isinstance(e, subprocess.CalledProcessError) and e.stderr:
detail = e.stderr.strip()
else:
detail = str(e)
print(f"FAILED: {source_url} -> {gcs_url}: {detail}", file=sys.stderr)
return "FAILED", source_url, detail
finally:
if temp_filename and os.path.exists(temp_filename):
os.remove(temp_filename)
def print_urls(
urls: list[str],
candidates: dict[str, list[RepoUrlRef]],
verbose: bool,
) -> None:
"""Prints URLs to stdout, optionally annotated with repository names."""
for u in urls:
if verbose:
repo_names = sorted({ref.repo_name for ref in candidates[u]})
print(f"{u}\t# {', '.join(repo_names)}")
else:
print(u)
def main() -> None:
"""Main entry point."""
default_workspace = pathlib.Path(
os.environ.get("BUILD_WORKSPACE_DIRECTORY", pathlib.Path.cwd())
)
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--workspace",
type=pathlib.Path,
default=default_workspace,
help="Path to the Bazel workspace root (default: %(default)s).",
)
parser.add_argument(
"--bazel",
default="bazel",
help="Bazel binary to invoke (default: bazel).",
)
parser.add_argument(
"--domains",
nargs="+",
default=None,
help=(
"Domains to mirror (default: parsed from bazel_downloader.cfg, plus"
" any repo explicitly listing mirror.bazel.build in its urls)."
),
)
parser.add_argument(
"--include-mirrored",
action="store_true",
help=(
"Print all mirrorable URLs without filtering out already-mirrored"
" ones."
),
)
parser.add_argument(
"--upload",
action="store_true",
help=(
"Upload missing URLs to the GCS mirror bucket (requires gcloud"
" storage)."
),
)
parser.add_argument(
"--gcs-bucket",
default=DEFAULT_GCS_BUCKET,
help="GCS bucket name when --upload is set (default: %(default)s).",
)
parser.add_argument(
"--verbose",
"-v",
action="store_true",
help="Print canonical repository names alongside each URL.",
)
args = parser.parse_args()
if args.domains is not None:
domains = set(args.domains)
include_explicit_mirror_repos = False
else:
domains = parse_downloader_cfg_domains(args.workspace)
include_explicit_mirror_repos = True
candidates, explicit_mirrors, lockfile_errors = collect_candidates(
args.workspace,
args.bazel,
domains,
include_explicit_mirror_repos,
)
if args.include_mirrored:
print_urls(sorted(candidates), candidates, args.verbose)
if lockfile_errors:
sys.exit(1)
return
missing_urls = find_missing_urls(candidates, explicit_mirrors)
if not args.upload:
print_urls(missing_urls, candidates, args.verbose)
if lockfile_errors:
sys.exit(1)
return
if not missing_urls:
print("All URLs are already mirrored. Nothing to upload.")
if lockfile_errors:
sys.exit(1)
return
print(
f"Uploading {len(missing_urls)} missing URL(s) to"
f" gs://{args.gcs_bucket} ..."
)
results = [upload_to_gcs(u, args.gcs_bucket) for u in missing_urls]
successes = [r for r in results if r[0] == "SUCCESS"]
skips = [r for r in results if r[0] == "SKIPPED"]
failures = [r for r in results if r[0] == "FAILED"]
print(
f"\nMirroring complete. Success: {len(successes)},"
f" Skipped: {len(skips)}, Failed: {len(failures)}"
)
if failures or lockfile_errors:
sys.exit(1)
if __name__ == "__main__":
main()