diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cdd8b42ade2..307c2d21449 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -42,7 +42,7 @@ repos: rev: v1.5.5 hooks: - id: forbid-tabs - exclude: ".gitmodules|patches/.*|docs/development/git_chores.md|build_tools/packaging/linux/template/debian_rules.j2|third-party/sysdeps/linux/libdrm/patch_source.sh" + exclude: ".gitmodules|patches/.*|docs/development/git_chores.md|build_tools/packaging/linux/template/debian_rules.j2|build_tools/packaging/linux/template/repo/deb/rules.j2|third-party/sysdeps/linux/libdrm/patch_source.sh" - repo: https://github.com/rhysd/actionlint rev: v1.7.10 diff --git a/build_tools/.gitignore b/build_tools/.gitignore deleted file mode 100644 index f606d5e0b04..00000000000 --- a/build_tools/.gitignore +++ /dev/null @@ -1 +0,0 @@ -repo diff --git a/build_tools/packaging/linux/build_repo_package.py b/build_tools/packaging/linux/build_repo_package.py new file mode 100644 index 00000000000..101fccbd7a5 --- /dev/null +++ b/build_tools/packaging/linux/build_repo_package.py @@ -0,0 +1,1094 @@ +#!/usr/bin/env python3 + +# Copyright Advanced Micro Devices, Inc. +# SPDX-License-Identifier: MIT + +r"""Build the ``amdrocm-repo`` package that configures a system package manager +(apt/dnf/zypper) to install AMD ROCm from the public ROCm repositories. + +One ``amdrocm-repo`` package is generated per target OS profile and RFC0012 +stream. It ships a single repository definition and, for signed streams, the AMD +signing key, so that after installing the package a user can install ROCm with +their native package manager. The signing key is fetched at build time and +embedded in the package; it is never stored in the source tree. + +Package naming +-------------- + +Every OS profile builds a *different* package, because each embeds its own +repository URL. The profile therefore has to reach the package name, or two of +them would be indistinguishable. Each ecosystem does that its own way: use the +distro token the ecosystem already defines where there is one, and the +``--os-profile`` name where there is not. + + deb the profile goes in the version: 10.0.0-1~ubuntu2404 + rpm the profile goes in the Release: 10.0.0-1.stable.el8 + +``-1`` is ``--version-suffix``, this package's own build number (default 1), +bumped to re-ship the repository configuration at an unchanged ROCm version. + +``--rocm-version`` is used as given, for every stream. Pass the version of the +ROCm packages in the repository the package configures, as +compute_rocm_package_version.py computes it for the stream's release type; CI +passes exactly that. The package then sorts the way those ROCm packages do: +below stable at the same ROCm version. rc is spelled differently per format +(``~preN`` on deb, ``~rcN`` on rpm). + +Worked examples with the default ``--version-suffix``. A build_id stream +additionally takes ``--repo-sub-folder`` (nightly here uses 20260716-12345); a +flat stream rejects it: + + --os-profile --stream --rocm-version resulting package file + ------------ -------- --------------- --------------------------------------------------- + ubuntu2404 stable 10.0.0 amdrocm-repo_10.0.0-1~ubuntu2404_all.deb + ubuntu2404 rc 10.0.0~pre1 amdrocm-repo_10.0.0~pre1-1~ubuntu2404_all.deb + ubuntu2404 nightly 10.0.0~20260716 amdrocm-repo_10.0.0~20260716-1~ubuntu2404_all.deb + rhel8 stable 10.0.0 amdrocm-repo-10.0.0-1.stable.el8.noarch.rpm + rhel8 rc 10.0.0~rc1 amdrocm-repo-10.0.0~rc1-1.rc.el8.noarch.rpm + rhel8 nightly 10.0.0~20260716 amdrocm-repo-10.0.0~20260716-1.nightly.el8.noarch.rpm + rhel10 stable 10.0.0 amdrocm-repo-10.0.0-1.stable.el10.noarch.rpm + sles16 stable 10.0.0 amdrocm-repo-10.0.0-1.stable.sles16.noarch.rpm + +``rc`` configures the release-candidate repository for the next GA, and nightly +builds a later ROCm version still, so moving from either back to stable is +usually a downgrade. rc's repo file is ``amdrocm-stablerc`` -- see STREAM_IDS. + +``` +python build_repo_package.py \ + --os-profile ubuntu2404 \ + --stream stable \ + --repo-base-url https://stable.repo.amd.com/rocm/core/packages \ + --rocm-version 10.0.0 \ + --dest-dir ./output +``` + +The same build for a per-build stream, which pins the repository to one build +folder and ships no signing key (those repositories are unsigned): + +``` +python build_repo_package.py \ + --os-profile rhel8 \ + --stream nightly \ + --repo-base-url https://nightly.repo.amd.com/rocm/core/packages \ + --repo-sub-folder 20260716-12345 \ + --rocm-version 10.0.0~20260716 \ + --dest-dir ./output +``` +""" + +import argparse +import json +import re +import shutil +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request + +from datetime import datetime, timezone +from email.utils import format_datetime +from pathlib import Path, PurePosixPath + +from jinja2 import Environment, FileSystemLoader, select_autoescape + +SCRIPT_DIR = Path(__file__).resolve().parent + +# The package name. The repository identifier used for the repo file and its +# section header is derived per stream by ``repo_id()``. A single repository is +# configured per package. +PACKAGE_NAME = "amdrocm-repo" +REPO_ID_PREFIX = "amdrocm" +REPO_NAME = "AMD ROCm" + +# The deb suite the repositories publish under (``dists//``). Used to +# locate the index when verifying a repo URL. It is written literally in +# template/repo/deb/amdrocm.sources.j2; a test asserts the two agree. +# +# One suite for every stream, matching what the servers actually publish rather +# than naming the suite after the stream. Every stream serves ``dists/stable/`` +# and declares ``Suite: stable`` / ``Codename: stable`` inside its own Release +# file; a per-stream suite such as ``dists/stablerc/`` returns 404. Pointing apt +# at a stream-named suite would therefore produce a package that installs +# cleanly and then fails on the user's first update, so this follows the server. +DEB_SUITE = "stable" + +MAINTAINER = "ROCm Dev Support " + +# Where each package installs the signing key, and which the repo files +# reference. deb consumes a dearmored (binary) keyring via Signed-By; rpm +# references the (armored) key via gpgkey=file://. +# +# Both are named for this package (amdrocm) rather than the generic "rocm", so +# that no other package or hand-written setup step can claim the same path. +# dpkg and rpm both refuse to unpack two packages owning one path, so a generic +# name risks making this package uninstallable alongside something unrelated. +# This is precautionary: amdgpu-install 31.50 keeps its key elsewhere +# (/etc/apt/keyrings/rocm.gpg, /etc/amdgpu-install/rocm.gpg.key), so it is not +# a collision we have observed. The overlap that does exist with that package +# is the repository definition, and Conflicts handles it -- see +# LEGACY_INSTALLER_PACKAGE. +# These are installed names only; the key itself is fetched from the URL +# given by --gpg-key-url. +# +# /etc/pki/rpm-gpg is the RHEL-family convention. SUSE has no filesystem +# convention for third-party repo keys -- its trust store is the rpm database, +# and gpgkey= is only an import source -- so the same path is used there. No +# SUSE package provides /etc/pki/rpm-gpg, so nothing can collide with it. +DEB_KEYRING_PATH = "/usr/share/keyrings/amdrocm.gpg" +RPM_GPG_KEY_PATH = "/etc/pki/rpm-gpg/RPM-GPG-KEY-amdrocm" + +# The legacy installer this package must not be co-installed with. It ships its +# own enabled ROCm repository as a packaged file -- /etc/apt/sources.list.d/ +# rocm.list on deb, [rocm] in /etc/yum.repos.d/rocm.repo on rpm -- both pointing +# at repo.radeon.com. Its repo files use different names than ours, so nothing +# stops the two being installed together; the result is a system with two ROCm +# repositories configured. It also grants itself precedence (an apt pin at +# priority 600 against a 500 default, and priority=50 against dnf's 99 default), +# so it wins package resolution rather than reporting a conflict. +# RFC0012's "Repository Package" section names this rule and defers its +# definition to a separate repository-package RFC. +# +# What the declaration actually achieves differs by package manager, so do not +# describe it as closing the overlap outright: +# +# dnf/zypper refuse the pair with an explicit conflict error, and erasing +# that package also removes its %config repo files. Clean. +# apt satisfies the conflict by removing the package to "rc" state. +# Its repo file and priority pin are conffiles, so both survive. +# The packages are no longer co-installed, but the repository +# overlap remains until the user purges rather than removes. +# +# The Debian remedy is therefore "apt purge", which the documentation states. +LEGACY_INSTALLER_PACKAGE = "amdgpu-install" + +GPG_FETCH_TIMEOUT_SEC = 60 +# Retry the key fetch so a transient network/CDN blip does not fail a build. +GPG_FETCH_ATTEMPTS = 3 +GPG_FETCH_BACKOFF_SEC = 3 +# Upper bound on the fetched signing key. A real armored key is a few KB; this +# bounds memory use if a repository host returns an unexpectedly large body. +MAX_GPG_KEY_BYTES = 1 << 20 # 1 MiB + +# Bounds for the optional repository reachability check (--verify-repo-url). +REPO_VERIFY_TIMEOUT_SEC = 30 +REPO_VERIFY_ATTEMPTS = 3 +REPO_VERIFY_BACKOFF_SEC = 3 +# HTTP statuses worth retrying: request timeout and rate limiting. Any other 4xx +# means the repository is not at that URL, which retrying cannot change. +RETRYABLE_HTTP_STATUS = frozenset({408, 429}) + +# Primary-key fingerprint of the AMD ROCm signing key. The key is fetched over +# the network at build time and embedded as the package's trust anchor, so its +# identity is pinned here and verified after fetch. Rotating the repository +# signing key requires updating this value. +EXPECTED_KEY_FINGERPRINT = "D0F004A0025A1145C7807FCD0701EAC4D5E02107" + +# Accepted input shapes. rocm_version is rendered into the package version +# fields and changelog, and repo_sub_folder into the repository URL, so they are +# constrained to characters that cannot alter the surrounding syntax. +# repo_base_url is concatenated into the repo files as well. Use \Z (not $) so a +# trailing newline is not accepted. +# +# No "-": an rpm Version cannot contain one, since rpm uses it to separate the +# version from the release (see rpm_version_release). A pre-release marker +# belongs in the "~rc1" form, which both rpm and dpkg sort before the plain +# version. +# +# The sub-folder must have the shape of a published build folder, +# -; anything else names a folder that does not exist. +_VERSION_RE = re.compile(r"^[0-9][0-9A-Za-z.+~]*\Z") +_SUB_FOLDER_RE = re.compile(r"^[0-9]{8}-[0-9A-Za-z._]+\Z") + +# --version-suffix is the package's own build number, bumped to re-ship the +# repository configuration at an unchanged ROCm version. It is stricter than +# _VERSION_RE in two ways: +# +# no "-" deb_version joins it to the upstream version with the single "-" +# that separates a debian revision, and rpm forbids one in a Release. +# no "~" deb_version appends "~" after it, and "~" sorts before +# everything, so a "~" inside the suffix would reorder the two. +_VERSION_SUFFIX_RE = re.compile(r"^[0-9][0-9A-Za-z.+]*\Z") + +# The RFC0012 streams, each served from .repo.amd.com. +# +# A stream has one of two shapes, and everything else follows from the shape: +# +# flat one repository per distro, holding every retained version. +# Signed, so the package ships the key and enables gpgcheck. +# build_id the same tree with a segment naming a single +# build. Unsigned, so the package ships no key and trusts the repo. +# +# Signedness is not an independent axis: the flat streams are the ones that +# publish InRelease/Release.gpg and repomd.xml.asc, and the build_id streams +# publish none of them. Branch on the shape, never on the stream name. +_FLAT = "flat" +_BUILD_ID = "build_id" + +STREAM_SHAPES = { + "stable": _FLAT, + "rc": _FLAT, + "nightly": _BUILD_ID, +} +STREAMS = tuple(STREAM_SHAPES) + +# Adding a stream is an entry here, any alternate names it carries (see +# STREAM_IDS), and its golden tests. A build_id stream that publishes several +# builds a day, as the dev repository does, would also need the run ID as +# --version-suffix: its version carries only the date, so two of its packages +# would otherwise share one. + +# Alternate names a stream is known by, where they differ from the stream id. +# Streams absent here, or missing a key, use the stream id unchanged. +# +# "file_id" the installed repo file. RFC0012 names rc's tier package +# amdrocm-repo-stablerc, so the file is amdrocm-stablerc while +# the subdomain stays rc. Matching it now keeps the eventual +# per-tier split from also having to rename an installed file. +STREAM_IDS = { + "rc": {"file_id": "stablerc"}, +} + + +def _stream_id(stream: str, key: str) -> str: + """Return a stream's alternate name for ``key``, or the stream name.""" + return STREAM_IDS.get(stream, {}).get(key, stream) + + +# "dist_tag" is appended to the rpm Release so two profiles cannot produce an +# identically named package. Stated per profile rather than taken from rpm's +# %{?dist}, which reads the *build host* rather than the target (a rhel8 build +# in a RHEL 9 container would stamp ".el9") and is empty on SUSE, so every SUSE +# profile would share one Release -- invisible while sles16 is the only one. +# +# ".el8"/".el10" reproduce what %{?dist} yields there, so RHEL names do not +# change. ".sles16" is a deliberate departure from SUSE's own convention, which +# leads the Release with a numeric code (160000.2.2 on SLE 16.0): that is a +# prefix where RHEL's is a suffix, so no single placement satisfies both. +# +# deb profiles carry no dist_tag -- deb_version() puts the profile in the +# version -- so read it with .get(); build_context() runs for both types. +OS_PROFILES = { + "ubuntu2404": { + "family": "debian", + "pkg_type": "deb", + "description": "Ubuntu 24.04", + }, + "rhel8": { + "family": "rhel", + "pkg_type": "rpm", + "rpm_repo_dir": "/etc/yum.repos.d", + "rpm_refresh_cmd": "sudo dnf makecache", + "dist_tag": ".el8", + "description": "RHEL/Rocky/OL 8", + }, + "rhel10": { + "family": "rhel", + "pkg_type": "rpm", + "rpm_repo_dir": "/etc/yum.repos.d", + "rpm_refresh_cmd": "sudo dnf makecache", + "dist_tag": ".el10", + "description": "RHEL/Rocky/OL 10", + }, + "sles16": { + "family": "sles", + "pkg_type": "rpm", + "rpm_repo_dir": "/etc/zypp/repos.d", + "rpm_refresh_cmd": "sudo zypper refresh", + "dist_tag": ".sles16", + "description": "SLES 16", + }, +} + + +# --- URL / signing derivation ------------------------------------------------ +# +# Every stream is served per distro under a common base: +# +# flat // (deb) +# //x86_64/ (rpm) +# build_id /// (deb) +# ///x86_64/ (rpm) +# +# where is https://.repo.amd.com/rocm/core/packages. +# +# The per-format trees (/deb/, /rpm/) are also served, and hold the +# same builds, so a package pointing at either one works today. They are not +# used here: the published install instructions configure the per-distro tree, +# and a stream that ever drops the per-format alias would break silently in the +# field rather than in CI. A dual-served layout cannot tell a right mapping from +# a wrong one. +# +# The signing key is not under at all -- it sits beside core/, two +# levels up -- so its URL is passed in whole rather than derived here. That +# also keeps the key's location a property of the publisher rather than an +# assumption baked into this tool. + + +def repo_id(stream: str) -> str: + """Return the repo-file stem and section id for a stream. + + ``amdrocm-stable``, ``amdrocm-stablerc`` etc. The stem drives the installed + filename in both families -- ``debian/install`` maps ``{repo_id}.sources`` + and the spec ``%files`` lists ``{repo_id}.repo`` -- so naming the stream + here is what keeps two streams' repo files from overwriting one another on + disk. + + The stem is not always the stream name: rc's file is ``amdrocm-stablerc`` + while its subdomain stays ``rc``. See STREAM_IDS. + """ + stream_shape(stream) # reject unknown streams before they reach a filename + return f"{REPO_ID_PREFIX}-{_stream_id(stream, 'file_id')}" + + +def stream_shape(stream: str) -> str: + """Return the layout shape for a stream. + + Raises: + ValueError: If the stream is unknown. + """ + try: + return STREAM_SHAPES[stream] + except KeyError as e: + raise ValueError( + f"stream={stream!r} is invalid, expected one of {STREAMS}" + ) from e + + +def is_signed(stream: str) -> bool: + """Whether this stream's public repository is signed. + + Signedness follows the shape: flat streams publish ``InRelease`` / + ``Release.gpg`` (deb) and ``repomd.xml.asc`` (rpm); build_id streams publish + none of them and are consumed with ``trusted=yes`` / ``gpgcheck=0``. + """ + return stream_shape(stream) == _FLAT + + +def repo_baseurl( + repo_base_url: str, + pkg_type: str, + os_profile: str, + stream: str, + repo_sub_folder: str, +) -> str: + """Return the repository baseurl baked into the repo file. + + deb returns the repo root, beneath which apt resolves ``dists/stable/main/``; + rpm returns the directory containing ``repodata/``. ``repo_sub_folder`` is + the ``YYYYMMDD-runid`` segment naming a single build, required by the + build_id shape and rejected for the flat one. + + Raises: + ValueError: If the stream is unknown, or the sub-folder does not match + the stream's shape. + """ + base = repo_base_url.rstrip("/") + shape = stream_shape(stream) + # Silently dropping a missing sub-folder would emit a flat URL for a + # build_id stream -- indistinguishable from a correct stable URL, and + # serving nothing, so the package would install and fail on first refresh. + if shape == _BUILD_ID and not repo_sub_folder: + raise ValueError(f"stream={stream!r} requires a build sub-folder") + if shape != _BUILD_ID and repo_sub_folder: + raise ValueError(f"stream={stream!r} takes no build sub-folder") + parts = [base, os_profile] + if shape == _BUILD_ID: + parts.append(repo_sub_folder) + if pkg_type != "deb": + parts.append("x86_64") + return "/".join(parts) + "/" + + +def repo_metadata_url(baseurl: str, pkg_type: str) -> str: + """Return a repository index file that must exist under ``baseurl``. + + apt reads ``dists//Release``; dnf and zypper read + ``repodata/repomd.xml``. Fetching one proves the baseurl serves a repository. + """ + if pkg_type == "deb": + return f"{baseurl}dists/{DEB_SUITE}/Release" + return f"{baseurl}repodata/repomd.xml" + + +def verify_repo_url(baseurl: str, pkg_type: str) -> None: + """Fail the build if ``baseurl`` does not serve a package repository. + + The baseurl is baked into the package and is only exercised the first time a + user refreshes their package manager, so an unreachable one would otherwise + ship undetected. Transient failures are retried; a repository that is simply + not there fails immediately. + + The scheme is not re-checked here: this response is neither trusted nor + retained, and the signing key (which is) enforces https separately. + """ + url = repo_metadata_url(baseurl, pkg_type) + last_error: Exception | None = None + for attempt in range(1, REPO_VERIFY_ATTEMPTS + 1): + try: + print(f"Verifying repository (attempt {attempt}): {url}") + with urllib.request.urlopen(url, timeout=REPO_VERIFY_TIMEOUT_SEC) as resp: + status = getattr(resp, "status", None) or resp.getcode() + if status == 200: + print(f"Repository verified: {baseurl}") + return + # urlopen already raised for the error codes, so anything else here + # is a URL that answers but does not serve a repository index. + # Retrying cannot change that, so fail now rather than sleeping + # through the remaining attempts. + raise RuntimeError( + f"repository is not reachable at {baseurl} " + f"(checked {url}): unexpected HTTP status {status}" + ) + except urllib.error.HTTPError as e: + # A missing or forbidden index is a wrong URL, not a blip. Some + # object stores answer 403 rather than 404 for absent keys, so treat + # both as final; only rate limiting and timeouts are worth retrying. + if e.code not in RETRYABLE_HTTP_STATUS: + raise RuntimeError( + f"repository is not reachable at {baseurl} " + f"(checked {url}): HTTP {e.code}" + ) from e + last_error = e + except (urllib.error.URLError, TimeoutError, ConnectionError) as e: + last_error = e + if attempt < REPO_VERIFY_ATTEMPTS: + time.sleep(REPO_VERIFY_BACKOFF_SEC * attempt) + raise RuntimeError( + f"repository is not reachable at {baseurl} (checked {url}): {last_error}" + ) + + +def _require_https(url: str, what: str) -> None: + """Reject a non-https URL used to fetch trusted key material.""" + if urllib.parse.urlsplit(url).scheme != "https": + raise ValueError(f"{what} must use https to protect the key in transit: {url}") + + +def _valid_repo_base_url(url: str) -> bool: + """Whether a repo base URL is a well-formed http(s) URL with no control chars. + + The value is concatenated into the generated repo files, so whitespace or + control characters (e.g. a newline) that could inject extra directives are + rejected here. + """ + if any(c.isspace() or ord(c) < 0x20 for c in url): + return False + parts = urllib.parse.urlsplit(url) + return parts.scheme in ("http", "https") and bool(parts.netloc) + + +def rpm_version_release( + stream: str, + rocm_version: str, + version_suffix: str, + dist_tag: str, +) -> tuple[str, str]: + """Return (Version, Release) for the rpm package. + + Version is ``--rocm-version`` as given (see deb_version). Release is + ``.``, e.g. ``1.stable.el8``. Each part + exists to keep two packages from sharing a name: + + - ``stream`` -- without it, installing one stream's package over another at + the same version is a silent no-op: the package manager reports success + and leaves the old repository configured. + - ``dist_tag`` -- one package is built per OS profile and their contents + differ, so the profile must reach the name too (see OS_PROFILES). + - ``version_suffix`` -- this package's own build number, so the repository + configuration can be re-shipped at an unchanged ROCm version. + """ + return rocm_version, f"{version_suffix}.{stream}{dist_tag}" + + +# An rpm %changelog date has to be English whatever LC_TIME the build machine +# happens to use. Both strftime("%a %b") and calendar.day_abbr read the process +# locale, so the names are pinned here instead. This mirrors what the standard +# library does for the deb changelog: email.utils.format_datetime carries its +# own English tables for the same reason. +_CHANGELOG_DAY_ABBR = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") +_CHANGELOG_MONTH_ABBR = ( + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +) + + +def rpm_changelog_date(when: datetime) -> str: + """Return ``when`` formatted as an rpm %changelog date, always in English.""" + return ( + f"{_CHANGELOG_DAY_ABBR[when.weekday()]} " + f"{_CHANGELOG_MONTH_ABBR[when.month - 1]} " + f"{when.day:02d} {when.year}" + ) + + +def deb_version(rocm_version: str, version_suffix: str, os_profile: str) -> str: + """Return the deb package version, e.g. ``10.0.0-1~ubuntu2404``. + + The value is ``-``. The upstream part is + ``--rocm-version`` as given, for every stream. CI passes the version that + compute_rocm_package_version.py computes for the stream's release type, + which is also the version of the ROCm packages in that stream's repository + (``10.1.0``, ``10.1.0~pre3``, ``10.2.0~20261013``), so this package sorts + the way they do. + + The revision carries ``~``: + + - ``version_suffix`` is this package's own build number, so the repository + configuration can be re-shipped at an unchanged ROCm version. + - ``os_profile`` is part of the version because one package is built per + profile and their contents differ -- each embeds its own repository URL. + Without it every debian-family profile would produce an identically named + package holding different files. + + Profile names rather than Debian codenames: codenames are not + alphabetically ordered, so ``10.0.0-1~bullseye1`` sorts *above* + ``10.0.0-1~bookworm1`` and an in-place Debian 11 -> 12 upgrade would see the + newer package as a downgrade. ``ubuntu2404``/``debian12`` are monotonic, and + match both ``--os-profile`` and the ``repo//`` publish path. + + Note that ``~`` sorts before everything, including nothing at all, so + ``10.0.0-1~ubuntu2404`` ranks *below* a bare ``10.0.0-1``. Every package + built here carries a profile, so that comparison never arises between two of + ours -- but it is why the separator must stay ``~`` and not become ``.``. + """ + return f"{rocm_version}-{version_suffix}~{os_profile}" + + +# --- signing key ------------------------------------------------------------- + + +def _fetch_signing_key(url: str) -> bytes: + """Fetch the armored signing key over https, size-bounded, with retries. + + Network and HTTP errors are retried; a scheme downgrade or an over-size + body fails immediately. + """ + # The fetched key is embedded and trusted, so it must travel over https. + _require_https(url, "signing key URL") + last_error: Exception | None = None + for attempt in range(1, GPG_FETCH_ATTEMPTS + 1): + try: + print(f"Fetching signing key (attempt {attempt}): {url}") + with urllib.request.urlopen(url, timeout=GPG_FETCH_TIMEOUT_SEC) as resp: + # urlopen follows redirects; re-check the scheme actually served + # so an https URL cannot be silently downgraded to http. + _require_https(resp.geturl(), "signing key URL (after redirects)") + # Read one byte past the cap to detect an over-size body. + key = resp.read(MAX_GPG_KEY_BYTES + 1) + if len(key) > MAX_GPG_KEY_BYTES: + raise ValueError( + f"signing key exceeds {MAX_GPG_KEY_BYTES} bytes: {url}" + ) + return key + except (urllib.error.URLError, TimeoutError, ConnectionError) as e: + last_error = e + if attempt < GPG_FETCH_ATTEMPTS: + time.sleep(GPG_FETCH_BACKOFF_SEC * attempt) + raise RuntimeError( + f"failed to fetch signing key after {GPG_FETCH_ATTEMPTS} attempts: {url}" + ) from last_error + + +def _run_gpg(argv: list[str], **kwargs) -> subprocess.CompletedProcess: + """Run gpg, reporting a missing binary by name instead of a traceback. + + A signed stream reaches gpg in two places: the deb path always, to dearmor + the keyring, and either path when the key is fetched, to check its + fingerprint. + An rpm build given --gpg-key-file does neither, so it needs no gpg at all. + """ + try: + return subprocess.run(argv, check=True, **kwargs) + except FileNotFoundError as e: + raise RuntimeError( + "gpg is required to build a signed stream: it dearmors the deb " + "keyring, and checks the fingerprint of a fetched key. Install " + "gnupg/gnupg2. Note that --gpg-key-file skips the fetch, not the deb " + "keyring step, so a signed deb build needs gpg either way" + ) from e + + +def _primary_fingerprints(listing: str) -> list[str]: + """Return the primary-key fingerprints in ``gpg --with-colons`` output. + + Each ``fpr`` record belongs to the ``pub`` or ``sub`` record before it; only + those following a ``pub`` identify a primary key. + """ + primaries = [] + owner = None + for line in listing.splitlines(): + record = line.split(":") + if record[0] in ("pub", "sub"): + owner = record[0] + elif record[0] == "fpr" and owner == "pub": + primaries.append(record[9]) + return primaries + + +def _extract_pinned_key(armored: bytes) -> bytes: + """Return the pinned AMD ROCm key, armored, from a fetched key file. + + The file must hold exactly one primary key, the pinned one. Checking only + the first key would let a file with other keys appended pass, and every + key in the embedded file is trusted on the user's machine. The key is + returned as gpg re-exports it, which keeps the primary key and its valid + subkeys (the packages are signed by a subkey) and drops anything else. + ``import-export`` writes no keyring, so no gpg daemon is started. + """ + with tempfile.TemporaryDirectory() as home: + key_path = Path(home) / "key.asc" + key_path.write_bytes(armored) + listing = _run_gpg( + ["gpg", "--homedir", home, "--with-colons", "--show-keys", str(key_path)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ).stdout.decode(errors="replace") + primaries = _primary_fingerprints(listing) + if primaries != [EXPECTED_KEY_FINGERPRINT]: + raise ValueError( + f"fetched signing key must be exactly the pinned key " + f"{EXPECTED_KEY_FINGERPRINT}; found {primaries or 'no key'}" + ) + return _run_gpg( + [ + "gpg", + "--homedir", + home, + "--batch", + "--armor", + "--import-options", + "import-export", + "--import", + str(key_path), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ).stdout + + +def load_signing_key(args: argparse.Namespace) -> bytes: + """Return the armored signing key bytes for a signed stream. + + Read from ``--gpg-key-file`` when given (offline/test builds), otherwise + fetched from ``--gpg-key-url`` at build time. Never written into the + source tree. + + Two guards apply to the fetch only: the https requirement + (``_require_https``, including after redirects) and the pinned key (the + file must hold only that key, and only it is embedded). They exist to + detect a tampered or misconfigured repository, which is a remote input. A key passed with ``--gpg-key-file`` is a local file the + caller has already chosen, so it is embedded as provided and neither guard + runs; callers supplying their own key are responsible for it being the + right one. + + ``--gpg-key-url`` is still checked for http(s) shape by ``parse_args`` + whenever it is non-empty, including alongside ``--gpg-key-file``. That is a + separate, earlier check -- it rejects a malformed value rather than + vouching for the key. + """ + if args.gpg_key_file: + return args.gpg_key_file.read_bytes() + return _extract_pinned_key(_fetch_signing_key(args.gpg_key_url)) + + +def dearmor_key(armored: bytes) -> bytes: + """Convert an armored key to a binary keyring (for deb Signed-By).""" + result = _run_gpg( + ["gpg", "--dearmor"], + input=armored, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + return result.stdout + + +# --- templating -------------------------------------------------------------- + + +def get_jinja_env() -> Environment: + # keep_trailing_newline so rendered config files end with a newline (POSIX + # text-file hygiene; Jinja strips the final newline by default). + # + # autoescape matches deb_package.py and rpm_package.py: escaping is enabled + # for markup extensions only. Every template here renders a package-manager + # config file, not markup, and escaping one would corrupt it -- an "&" in a + # repository URL would become "&". The values interpolated into these + # files are constrained by the input patterns above, which is the control + # that actually applies to this syntax. + return Environment( + loader=FileSystemLoader(str(SCRIPT_DIR)), + autoescape=select_autoescape( + enabled_extensions=("html", "htm", "xml"), + default_for_string=True, + default=False, + ), + keep_trailing_newline=True, + trim_blocks=True, + lstrip_blocks=True, + ) + + +def _render( + env: Environment, template_path: str, output_path: Path, context: dict +) -> None: + # Explicit encoding: write_text() would otherwise follow the build + # machine's locale, and this module is deliberately locale-independent + # elsewhere (see rpm_changelog_date). + output_path.write_text( + env.get_template(template_path).render(context), encoding="utf-8" + ) + + +def build_context(args: argparse.Namespace, profile: dict) -> dict: + """Template fields shared by the repo file, spec, and deb metadata.""" + signed = is_signed(args.stream) + return { + "pkg_name": PACKAGE_NAME, + "repo_id": repo_id(args.stream), + "name": REPO_NAME, + "baseurl": repo_baseurl( + args.repo_base_url, + profile["pkg_type"], + args.os_profile, + args.stream, + args.repo_sub_folder, + ), + "signed": signed, + "rocm_version": args.rocm_version, + "maintainer": MAINTAINER, + "description_short": "AMD ROCm repository configuration", + "description_long": ( + "Configures the system package manager to install AMD ROCm packages." + ), + "deb_keyring_path": DEB_KEYRING_PATH, + # Split out so debian/install maps the staged file to the same place + # Signed-By points at, without repeating the name. + # + # PurePosixPath, not Path: these describe the layout of the package + # being built, not of the machine building it. Path follows the local + # flavour, so on Windows the parent would render as + # "\usr\share\keyrings" and apt would never find the keyring. + "deb_keyring_file": PurePosixPath(DEB_KEYRING_PATH).name, + "deb_keyring_dir": str(PurePosixPath(DEB_KEYRING_PATH).parent), + "rpm_gpg_key_path": RPM_GPG_KEY_PATH, + # Same reason as deb: the spec stages and installs by basename, so + # keep one source of truth for the name. + "rpm_gpg_key_file": PurePosixPath(RPM_GPG_KEY_PATH).name, + "rpm_repo_dir": profile.get("rpm_repo_dir", ""), + # .get(), not a subscript: this function runs before main() dispatches + # on pkg_type, so it is called for deb profiles too -- and those carry + # no dist_tag. + # + # Consumed by build_rpm_package, which hands it to rpm_version_release. + # The spec template renders the finished Release string and must not + # append the tag a second time. + "dist_tag": profile.get("dist_tag", ""), + # Same .get() reasoning as dist_tag: deb profiles reach here too. + "rpm_refresh_cmd": profile.get("rpm_refresh_cmd", ""), + # Declared for every stream: amdgpu-install configures a ROCm + # repository whichever stream this package points at. + "legacy_installer_package": LEGACY_INSTALLER_PACKAGE, + } + + +# --- rpm build --------------------------------------------------------------- + + +def build_rpm_package( + args: argparse.Namespace, profile: dict, context: dict, dest_dir: Path +) -> None: + env = get_jinja_env() + build_dir = dest_dir / "rpm" / PACKAGE_NAME + if build_dir.exists(): + shutil.rmtree(build_dir) + for subdir in ("SOURCES", "SPECS", "BUILD", "RPMS", "SRPMS"): + (build_dir / subdir).mkdir(parents=True, exist_ok=True) + sources_dir = build_dir / "SOURCES" + + _render( + env, + "template/repo/rpm/amdrocm.repo.j2", + sources_dir / f"{context['repo_id']}.repo", + context, + ) + + if context["signed"]: + # rpm consumes the armored key as-is (gpgkey=file://). + (sources_dir / PurePosixPath(RPM_GPG_KEY_PATH).name).write_bytes( + load_signing_key(args) + ) + + version, release = rpm_version_release( + args.stream, + args.rocm_version, + args.version_suffix, + context["dist_tag"], + ) + spec_context = { + **context, + "version": version, + "release": release, + "changelog_date": rpm_changelog_date(datetime.now(timezone.utc)), + } + spec_path = build_dir / "SPECS" / f"{PACKAGE_NAME}.spec" + _render(env, "template/repo/rpm/amdrocm-repo.spec.j2", spec_path, spec_context) + + print(f"Building rpm package: {PACKAGE_NAME} for {profile['description']}") + subprocess.run( + ["rpmbuild", "--define", f"_topdir {build_dir}", "-bb", str(spec_path)], + check=True, + ) + for rpm_file in (build_dir / "RPMS" / "noarch").glob("*.rpm"): + target = dest_dir / rpm_file.name + shutil.move(str(rpm_file), str(target)) + print(f"Package created: {target}") + + # Leave only the built packages behind. The rpmbuild tree is scratch, and + # CI publishes this directory, so anything left here ships with the + # package. Failing to clean up must not fail a build that succeeded. + shutil.rmtree(build_dir.parent, ignore_errors=True) + + +# --- deb build --------------------------------------------------------------- + + +def build_deb_package( + args: argparse.Namespace, profile: dict, context: dict, dest_dir: Path +) -> None: + env = get_jinja_env() + package_dir = dest_dir / "deb" / PACKAGE_NAME + deb_dir = package_dir / "debian" + if package_dir.exists(): + shutil.rmtree(package_dir) + deb_dir.mkdir(parents=True, exist_ok=True) + + deb_context = { + **context, + "deb_version": deb_version( + args.rocm_version, + args.version_suffix, + args.os_profile, + ), + "date": format_datetime(datetime.now(timezone.utc)), + } + + # Repository definition (deb822) and, for a signed stream, the dearmored key, + # placed at the package root and mapped into the filesystem by debian/install. + _render( + env, + "template/repo/deb/amdrocm.sources.j2", + package_dir / f"{deb_context['repo_id']}.sources", + deb_context, + ) + if context["signed"]: + (package_dir / PurePosixPath(DEB_KEYRING_PATH).name).write_bytes( + dearmor_key(load_signing_key(args)) + ) + + for name in ("control", "changelog", "rules", "postinst", "install"): + _render( + env, + f"template/repo/deb/{name}.j2", + deb_dir / name, + deb_context, + ) + (deb_dir / "rules").chmod(0o755) + (deb_dir / "postinst").chmod(0o755) + # Native source format: this package has no upstream outside this + # repository, so there is no separate tarball for a non-native format to + # reference. + # + # A native package conventionally carries no debian revision, and this one + # does (see deb_version). That convention is enforced when dpkg-source + # builds a source package, which a binary-only build never does: + # "dpkg-buildpackage -b" below runs only dpkg-source's --before-build and + # --after-build hooks and produces the .deb straight from debian/. + (deb_dir / "source").mkdir(exist_ok=True) + (deb_dir / "source" / "format").write_text("3.0 (native)\n", encoding="utf-8") + + print(f"Building deb package: {PACKAGE_NAME} for {profile['description']}") + subprocess.run( + ["dpkg-buildpackage", "-uc", "-us", "-b"], + cwd=package_dir, + check=True, + ) + # dpkg-buildpackage writes the .deb to the parent of the package directory. + for deb_file in package_dir.parent.glob("*.deb"): + target = dest_dir / deb_file.name + shutil.move(str(deb_file), str(target)) + print(f"Package created: {target}") + + # As for rpm: drop the build tree, and with it the .buildinfo and .changes + # dpkg-buildpackage leaves alongside the package. + shutil.rmtree(package_dir.parent, ignore_errors=True) + + +# --- CLI --------------------------------------------------------------------- + + +def list_profiles(pkg_type: str) -> list[str]: + """Return the OS profiles that build the given package type.""" + return [name for name, p in OS_PROFILES.items() if p["pkg_type"] == pkg_type] + + +def run_list_profiles(argv: list[str]) -> None: + """Print the OS profiles for a package type as a JSON array (CI matrix).""" + p = argparse.ArgumentParser( + prog="build_repo_package.py list-profiles", + description="Print the OS profiles for a package type as a JSON array.", + ) + p.add_argument("--pkg-type", required=True, choices=["deb", "rpm"]) + ns = p.parse_args(argv) + print(json.dumps(list_profiles(ns.pkg_type))) + + +def parse_args(argv=None) -> argparse.Namespace: + p = argparse.ArgumentParser( + description="Build the amdrocm-repo package that configures ROCm repositories.", + ) + p.add_argument( + "--os-profile", + required=True, + choices=sorted(OS_PROFILES.keys()), + help="Target distro profile", + ) + p.add_argument( + "--stream", + required=True, + choices=STREAMS, + help="RFC0012 repository stream to configure", + ) + p.add_argument( + "--repo-base-url", + required=True, + help=( + "Stream repository base URL, e.g. " + "https://stable.repo.amd.com/rocm/core/packages" + ), + ) + p.add_argument( + "--repo-sub-folder", + default="", + help="Build sub-folder for a per-build stream (YYYYMMDD-)", + ) + p.add_argument( + "--gpg-key-file", + type=Path, + default=None, + help=( + "Signing key file to embed (defaults to fetching --gpg-key-url). The " + "pinned-fingerprint check applies to the fetched key only; a key " + "given here is embedded as provided" + ), + ) + p.add_argument( + "--rocm-version", + required=True, + help=( + "Package version, used as given: the version of the ROCm packages " + "in the repository, as compute_rocm_package_version.py computes it " + "for the stream's release type (e.g. 10.0.0; 10.1.0~pre3 on deb " + "or 10.1.0~rc3 on rpm; 10.2.0~20261013)" + ), + ) + p.add_argument( + "--version-suffix", + default="1", + help=( + "This package's own build number, separate from --rocm-version " + "(default: 1). Bump it to re-ship the repository configuration " + "at an unchanged ROCm version: without it there would be no " + "higher version to publish, and the package manager would treat " + "the corrected package as already installed" + ), + ) + p.add_argument( + "--dest-dir", + type=Path, + required=True, + help="Output directory for built packages", + ) + p.add_argument( + "--gpg-key-url", + default="", + help=( + "URL of the armored signing key to embed, e.g. https://" + "stable.repo.amd.com/rocm/gpg/packages.gpg. Given whole rather " + "than derived: the key is not under --repo-base-url (packages sit " + "at /core/packages/ and the key beside core/), and where it " + "sits is the publisher's choice, not this tool's. The fetch is " + "https-only and the file must hold only the pinned key. " + "Required for a signed stream unless --gpg-key-file is given" + ), + ) + p.add_argument( + "--verify-repo-url", + action="store_true", + help=( + "Fail the build unless the configured repository is reachable. " + "No-op for a build_id stream, whose build folder is published by " + "the same run that builds the package" + ), + ) + args = p.parse_args(argv) + if is_signed(args.stream) and not args.gpg_key_file and not args.gpg_key_url: + p.error( + f"--gpg-key-url or --gpg-key-file is required for --stream {args.stream}" + ) + if args.gpg_key_url and not _valid_repo_base_url(args.gpg_key_url): + p.error(f"--gpg-key-url is not a valid http(s) URL: {args.gpg_key_url!r}") + build_id_shape = stream_shape(args.stream) == _BUILD_ID + if build_id_shape and not args.repo_sub_folder: + p.error(f"--repo-sub-folder is required for --stream {args.stream}") + if args.repo_sub_folder and not build_id_shape: + p.error(f"--repo-sub-folder is not valid for --stream {args.stream}") + # Validate values that are rendered into repo files, version fields, and + # maintainer metadata so they cannot alter the surrounding syntax. + if not _VERSION_RE.match(args.rocm_version): + p.error(f"--rocm-version has an unexpected format: {args.rocm_version!r}") + if not _VERSION_SUFFIX_RE.match(args.version_suffix): + p.error(f"--version-suffix has an unexpected format: {args.version_suffix!r}") + if not _valid_repo_base_url(args.repo_base_url): + p.error(f"--repo-base-url is not a valid http(s) URL: {args.repo_base_url!r}") + if args.repo_sub_folder and not _SUB_FOLDER_RE.match(args.repo_sub_folder): + p.error(f"--repo-sub-folder has an unexpected format: {args.repo_sub_folder!r}") + # rpmbuild requires an absolute _topdir; normalize so a relative --dest-dir works. + args.dest_dir = args.dest_dir.resolve() + return args + + +def main(argv=None) -> None: + if argv is None: + argv = sys.argv[1:] + if argv and argv[0] == "list-profiles": + run_list_profiles(argv[1:]) + return + args = parse_args(argv) + profile = OS_PROFILES[args.os_profile] + args.dest_dir.mkdir(parents=True, exist_ok=True) + context = build_context(args, profile) + # A build_id repository is published by the same run that builds this + # package, so its build folder does not exist yet and cannot be checked. + if args.verify_repo_url and stream_shape(args.stream) != _BUILD_ID: + verify_repo_url(context["baseurl"], profile["pkg_type"]) + if profile["pkg_type"] == "deb": + build_deb_package(args, profile, context, args.dest_dir) + else: + build_rpm_package(args, profile, context, args.dest_dir) + + +if __name__ == "__main__": + main() diff --git a/build_tools/packaging/linux/setup_repo_build_deps.sh b/build_tools/packaging/linux/setup_repo_build_deps.sh new file mode 100755 index 00000000000..f37daa76ddb --- /dev/null +++ b/build_tools/packaging/linux/setup_repo_build_deps.sh @@ -0,0 +1,104 @@ +#!/bin/bash +# Copyright Advanced Micro Devices, Inc. +# SPDX-License-Identifier: MIT + +# Install the system tools needed to build the amdrocm-repo package. +# +# Package mapping by --os-profile: +# +# - ubuntu* / debian* -> apt: debhelper-compat, dpkg-dev, build-essential +# - sles* -> zypper: rpm-build +# - else (RHEL-like) -> dnf: rpm-build +# +# gpg is installed for every profile: signed streams need it to dearmor +# the deb keyring and to check the signing key fingerprint. +# +# Python is installed separately by setup_python_cmd.sh. +# +# Commands are prefixed with sudo only when this runs as a non-root user on a +# system that has it. The rpm build images run as root and ship no sudo. +# +# Sample usage +# ------------ +# +# bash build_tools/packaging/linux/setup_repo_build_deps.sh --os-profile rhel10 + +set -euo pipefail + +OS_PROFILE="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --os-profile) + # Check for the value before reading it: this script runs under + # "set -u", so a trailing "--os-profile" would otherwise abort with + # bash's own unbound-variable error instead of the message below. + if [[ $# -lt 2 ]]; then + echo "Error: --os-profile requires a value" >&2 + exit 1 + fi + OS_PROFILE="$2" + shift 2 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +if [[ -z "$OS_PROFILE" ]]; then + echo "Error: --os-profile is required" >&2 + exit 1 +fi + +# Trim whitespace; lowercase for glob matching (e.g. SLES16 must match sles*) +OS_PROFILE="${OS_PROFILE#"${OS_PROFILE%%[![:space:]]*}"}" +OS_PROFILE="${OS_PROFILE%"${OS_PROFILE##*[![:space:]]}"}" +OS_PLC="${OS_PROFILE,,}" + +# Reject an unrecognised profile rather than letting it fall through to a +# package manager that is not the one it needs. Matched by family so a new +# release of a supported distro works without editing this script. +case "$OS_PLC" in + ubuntu*|debian*|rhel*|sles*) ;; + *) + echo "Error: unrecognised --os-profile '$OS_PROFILE'" >&2 + echo "Expected an ubuntu*, debian*, rhel* or sles* profile" \ + "(this project builds ubuntu2404, rhel8, rhel10, sles16)" >&2 + exit 1 + ;; +esac + +# Elevate only when sudo exists and we are not already root. The rpm build +# images (UBI, BCI) run as root and ship no sudo at all, so calling it there +# would fail; a developer following the documentation on a real RHEL or SLES +# machine is not root and does have it. An empty array expands to nothing, so +# the same command line serves both. +SUDO=() +if [[ "$(id -u)" -ne 0 ]] && command -v sudo >/dev/null 2>&1; then + SUDO=(sudo) +fi + +if [[ "$OS_PLC" == ubuntu* ]] || [[ "$OS_PLC" == debian* ]]; then + export DEBIAN_FRONTEND=noninteractive + "${SUDO[@]}" apt-get update + "${SUDO[@]}" apt-get install -y \ + ca-certificates \ + gnupg \ + debhelper-compat \ + dpkg-dev \ + build-essential +elif [[ "$OS_PLC" == sles* ]]; then + "${SUDO[@]}" zypper --non-interactive refresh + "${SUDO[@]}" zypper --non-interactive install -y \ + ca-certificates \ + gpg2 \ + rpm-build +else + "${SUDO[@]}" dnf makecache + "${SUDO[@]}" dnf install -y \ + ca-certificates \ + gnupg2 \ + rpm-build +fi diff --git a/build_tools/packaging/linux/template/repo/deb/amdrocm.sources.j2 b/build_tools/packaging/linux/template/repo/deb/amdrocm.sources.j2 new file mode 100644 index 00000000000..d4c50bac0ab --- /dev/null +++ b/build_tools/packaging/linux/template/repo/deb/amdrocm.sources.j2 @@ -0,0 +1,12 @@ +X-Repo-Id: {{ repo_id }} +Types: deb +URIs: {{ baseurl }} +Suites: stable +Components: main +Architectures: amd64 +Enabled: yes +{% if signed %} +Signed-By: {{ deb_keyring_path }} +{% else %} +Trusted: yes +{% endif %} diff --git a/build_tools/packaging/linux/template/repo/deb/changelog.j2 b/build_tools/packaging/linux/template/repo/deb/changelog.j2 new file mode 100644 index 00000000000..071194aa824 --- /dev/null +++ b/build_tools/packaging/linux/template/repo/deb/changelog.j2 @@ -0,0 +1,5 @@ +{{ pkg_name }} ({{ deb_version }}) stable; urgency=medium + + * ROCm {{ rocm_version }} repository configuration. + + -- {{ maintainer }} {{ date }} diff --git a/build_tools/packaging/linux/template/repo/deb/control.j2 b/build_tools/packaging/linux/template/repo/deb/control.j2 new file mode 100644 index 00000000000..716c329eea9 --- /dev/null +++ b/build_tools/packaging/linux/template/repo/deb/control.j2 @@ -0,0 +1,14 @@ +Source: {{ pkg_name }} +Section: admin +Priority: optional +Maintainer: {{ maintainer }} +Build-Depends: debhelper-compat (= 13) +Standards-Version: 4.6.2 +Homepage: https://github.com/ROCm/TheRock + +Package: {{ pkg_name }} +Architecture: all +Depends: ${misc:Depends} +Conflicts: {{ legacy_installer_package }} +Description: {{ description_short }} + {{ description_long }} diff --git a/build_tools/packaging/linux/template/repo/deb/install.j2 b/build_tools/packaging/linux/template/repo/deb/install.j2 new file mode 100644 index 00000000000..a7b24c8e8e0 --- /dev/null +++ b/build_tools/packaging/linux/template/repo/deb/install.j2 @@ -0,0 +1,4 @@ +{{ repo_id }}.sources /etc/apt/sources.list.d/ +{% if signed %} +{{ deb_keyring_file }} {{ deb_keyring_dir }}/ +{% endif %} diff --git a/build_tools/packaging/linux/template/repo/deb/postinst.j2 b/build_tools/packaging/linux/template/repo/deb/postinst.j2 new file mode 100644 index 00000000000..95c259cd830 --- /dev/null +++ b/build_tools/packaging/linux/template/repo/deb/postinst.j2 @@ -0,0 +1,36 @@ +#!/bin/sh +set -e + +if [ "$1" = "configure" ]; then + echo "AMD ROCm repository configured." + echo "Run 'sudo apt-get update' to refresh package lists." + + # apt satisfies this package's Conflicts by removing + # {{ legacy_installer_package }} rather than refusing it, and that + # package's repository definition and its apt pin are conffiles, so both + # survive the removal. The pin outranks this repository, so ROCm keeps + # resolving to the old one until the user purges rather than removes. + # + # Detect that one state -- removed but not purged -- rather than scanning + # apt's configuration. A scan cannot tell a leftover ROCm repository from + # the AMDGPU driver repository the same vendor ships and the user still + # needs, so it would name files that must not be removed. + # + # Every command below must succeed: this script runs under "set -e" and + # before #DEBHELPER#, so a non-zero status here fails the package install. + # dpkg-query exits non-zero for an unknown package, hence the guard. + legacy_state=$( + dpkg-query -W -f='${Status}' {{ legacy_installer_package }} 2>/dev/null || true + ) + + if [ "$legacy_state" = "deinstall ok config-files" ]; then + echo "" + echo "Warning: {{ legacy_installer_package }} must be fully removed before" + echo "the AMD ROCm repository will be used. Run" + echo "'sudo apt purge {{ legacy_installer_package }}'." + fi +fi + +#DEBHELPER# + +exit 0 diff --git a/build_tools/packaging/linux/template/repo/deb/rules.j2 b/build_tools/packaging/linux/template/repo/deb/rules.j2 new file mode 100644 index 00000000000..5f0c40ed74b --- /dev/null +++ b/build_tools/packaging/linux/template/repo/deb/rules.j2 @@ -0,0 +1,11 @@ +#!/usr/bin/make -f +%: + dh $@ + +# Pin root:root ownership and xz compression, matching template/debian_rules.j2, +# which every other ROCm package uses. The archive is root:root either way: +# without Rules-Requires-Root, dpkg-buildpackage runs "debian/rules binary" +# under the gain-root command (fakeroot) when not building as root, and with +# "Rules-Requires-Root: no" dh_builddeb adds --root-owner-group itself. +override_dh_builddeb: + dh_builddeb -- -Zxz --root-owner-group diff --git a/build_tools/packaging/linux/template/repo/rpm/amdrocm-repo.spec.j2 b/build_tools/packaging/linux/template/repo/rpm/amdrocm-repo.spec.j2 new file mode 100644 index 00000000000..e460ba85854 --- /dev/null +++ b/build_tools/packaging/linux/template/repo/rpm/amdrocm-repo.spec.j2 @@ -0,0 +1,49 @@ +Name: {{ pkg_name }} +Version: {{ version }} +Release: {{ release }} +BuildArch: noarch +Summary: {{ description_short }} +License: MIT +Vendor: Advanced Micro Devices, Inc +URL: https://github.com/ROCm/TheRock +Conflicts: {{ legacy_installer_package }} + +%description +{{ description_long }} + +%install +mkdir -p %{buildroot}{{ rpm_repo_dir }} +install -m 644 %{_sourcedir}/{{ repo_id }}.repo %{buildroot}{{ rpm_repo_dir }}/ +{% if signed %} +mkdir -p %{buildroot}/etc/pki/rpm-gpg +install -m 644 %{_sourcedir}/{{ rpm_gpg_key_file }} %{buildroot}{{ rpm_gpg_key_path }} +{% endif %} + +# Messages only, mirroring the deb postinst greeting so both package types +# behave the same on install. +# +# Nothing here may touch the rpm database -- in particular it must never run +# "rpm --import" to add the signing key. rpm holds the database lock for the +# whole transaction, so an import fails from this scriptlet and from a +# post-transaction one alike, and it fails only as a warning: the package +# installs looking healthy while the key was never added. Callers use their +# package manager's own flag instead ("dnf -y", "zypper --gpg-auto-import-keys"), +# which is what the documented install steps do. +# +# The legacy-installer warning the deb postinst prints has no counterpart here: +# dnf and zypper enforce Conflicts by refusing the pair outright, so there is no +# removed-but-not-purged state to detect. +%post +echo "AMD ROCm repository configured." +echo "Run '{{ rpm_refresh_cmd }}' to refresh package lists." + +%files +%defattr(-, root, root, -) +%config(noreplace) {{ rpm_repo_dir }}/{{ repo_id }}.repo +{% if signed %} +{{ rpm_gpg_key_path }} +{% endif %} + +%changelog +* {{ changelog_date }} {{ maintainer }} - {{ version }}-{{ release }} +- ROCm {{ rocm_version }} repository configuration. diff --git a/build_tools/packaging/linux/template/repo/rpm/amdrocm.repo.j2 b/build_tools/packaging/linux/template/repo/rpm/amdrocm.repo.j2 new file mode 100644 index 00000000000..1d34ea91c72 --- /dev/null +++ b/build_tools/packaging/linux/template/repo/rpm/amdrocm.repo.j2 @@ -0,0 +1,8 @@ +[{{ repo_id }}] +name={{ name }} +baseurl={{ baseurl }} +enabled=1 +gpgcheck={{ 1 if signed else 0 }} +{% if signed %} +gpgkey=file://{{ rpm_gpg_key_path }} +{% endif %} diff --git a/build_tools/tests/build_repo_package_test.py b/build_tools/tests/build_repo_package_test.py new file mode 100644 index 00000000000..c5c02490843 --- /dev/null +++ b/build_tools/tests/build_repo_package_test.py @@ -0,0 +1,1710 @@ +# Copyright Advanced Micro Devices, Inc. +# SPDX-License-Identifier: MIT + +"""Unit tests for build_repo_package.py. + +build_repo_package.py lives in build_tools/packaging/linux/, which is not on the +path that conftest.py sets up (it only adds build_tools/). Add it explicitly. +Importing the module is side-effect-free thanks to its ``__main__`` guard. +""" + +import json +import locale +import shutil +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath +from unittest import mock + +import pytest + +_LINUX_DIR = Path(__file__).resolve().parents[1] / "packaging" / "linux" +sys.path.insert(0, str(_LINUX_DIR)) + +import build_repo_package as brp # noqa: E402 +import compute_rocm_package_version as crpv # noqa: E402 + +STABLE_BASE = "https://stable.repo.amd.com/rocm/core/packages" +RC_BASE = "https://rc.repo.amd.com/rocm/core/packages" +NIGHTLY_BASE = "https://nightly.repo.amd.com/rocm/core/packages" +# The signing key, supplied whole. It sits beside core/ rather than under +# STABLE_BASE, which is why it is a separate input rather than derived from it. +STABLE_KEY_URL = "https://stable.repo.amd.com/rocm/gpg/packages.gpg" +NIGHTLY_SUB = "20260716-12345" + + +def _args(*extra: str): + """Parse args with sensible defaults, overridable by appending flags.""" + base = [ + "--os-profile", + "ubuntu2404", + "--stream", + "stable", + "--repo-base-url", + STABLE_BASE, + "--gpg-key-url", + STABLE_KEY_URL, + "--rocm-version", + "10.0.0", + "--dest-dir", + "/tmp/out", + ] + return brp.parse_args(base + list(extra)) + + +def _render(template: str, context: dict) -> str: + return brp.get_jinja_env().get_template(template).render(context) + + +# --- URL construction --------------------------------------------------------- +# +# Every stream is served per distro. The two shapes differ only by a build-id +# segment: +# +# flat //[x86_64/] +# build_id ///[x86_64/] +# +# These are pinned rather than derived, and the values match the published +# install instructions for stable and the live repositories for every stream. +# A URL that does not serve a repository only fails at the user's first +# metadata refresh, so a derived expectation would reproduce a builder bug +# instead of catching it. + +STABLE_EXPECTED = { + "ubuntu2404": f"{STABLE_BASE}/ubuntu2404/", + "rhel8": f"{STABLE_BASE}/rhel8/x86_64/", + "rhel10": f"{STABLE_BASE}/rhel10/x86_64/", + "sles16": f"{STABLE_BASE}/sles16/x86_64/", +} + +NIGHTLY_EXPECTED = { + "ubuntu2404": f"{NIGHTLY_BASE}/ubuntu2404/{NIGHTLY_SUB}/", + "rhel8": f"{NIGHTLY_BASE}/rhel8/{NIGHTLY_SUB}/x86_64/", + "rhel10": f"{NIGHTLY_BASE}/rhel10/{NIGHTLY_SUB}/x86_64/", + "sles16": f"{NIGHTLY_BASE}/sles16/{NIGHTLY_SUB}/x86_64/", +} + +# rc is flat and signed, exactly like stable, and served from its own subdomain. +# Its repo *file* is amdrocm-stablerc while its subdomain stays rc -- the two +# identifiers differ on purpose (see STREAM_IDS). +RC_EXPECTED = { + "ubuntu2404": f"{RC_BASE}/ubuntu2404/", + "rhel8": f"{RC_BASE}/rhel8/x86_64/", + "rhel10": f"{RC_BASE}/rhel10/x86_64/", + "sles16": f"{RC_BASE}/sles16/x86_64/", +} + + +def _baseurl(profile: str, stream: str, base: str, sub: str = "") -> str: + return brp.repo_baseurl( + base, brp.OS_PROFILES[profile]["pkg_type"], profile, stream, sub + ) + + +_STREAM_BASES = { + "stable": STABLE_BASE, + "rc": RC_BASE, + "nightly": NIGHTLY_BASE, +} + + +def _base_for(stream: str) -> str: + """The repository base a stream is served from.""" + return _STREAM_BASES[stream] + + +def _sub_for(stream: str) -> str: + """The build sub-folder a stream needs -- empty for a flat one.""" + return NIGHTLY_SUB if brp.stream_shape(stream) == brp._BUILD_ID else "" + + +# Derived from STREAMS rather than listed, so enabling a stream cannot quietly +# leave these per-stream assertions covering only the old set. A new stream also +# needs a _STREAM_BASES entry (tests using _base_for raise KeyError without one) +# and an EXPECTED_REPO_FILE_STEMS entry. +ALL_STREAMS = [(s, _sub_for(s)) for s in brp.STREAMS] + + +@pytest.mark.parametrize("profile,expected", sorted(STABLE_EXPECTED.items())) +def test_stable_baseurl_is_per_distro(profile, expected): + assert _baseurl(profile, "stable", STABLE_BASE) == expected + + +@pytest.mark.parametrize("profile,expected", sorted(NIGHTLY_EXPECTED.items())) +def test_nightly_baseurl_is_per_distro_and_carries_the_build_id(profile, expected): + assert _baseurl(profile, "nightly", NIGHTLY_BASE, NIGHTLY_SUB) == expected + + +@pytest.mark.parametrize("profile,expected", sorted(RC_EXPECTED.items())) +def test_rc_baseurl_is_per_distro(profile, expected): + # Same flat shape as stable -- no build-id segment. + assert _baseurl(profile, "rc", RC_BASE) == expected + + +def test_gpg_key_url_is_outside_the_packages_base(): + # The key sits beside core/ while packages live under core/packages/, so it + # cannot be reached by appending to the base. This is the whole reason the + # key URL is its own argument, so pin the relationship rather than trusting + # the comment that says so. + assert not STABLE_KEY_URL.startswith(STABLE_BASE) + # Nor by trimming: the key is not under the base's parent either. + assert not STABLE_KEY_URL.startswith(STABLE_BASE.rsplit("/", 1)[0]) + + +def test_key_url_is_used_verbatim(monkeypatch): + # Taking the URL whole -- rather than appending a hardcoded gpg/packages.gpg + # to a root -- is what keeps the key's location a publisher decision. A + # builder that still derived the tail would fetch a different URL here. + seen = [] + monkeypatch.setattr(brp, "_fetch_signing_key", lambda url: seen.append(url) or b"k") + monkeypatch.setattr(brp, "_extract_pinned_key", lambda key: key) + args = _args() + args.gpg_key_url = "https://example.invalid/somewhere/else/custom-key.gpg" + brp.load_signing_key(args) + assert seen == ["https://example.invalid/somewhere/else/custom-key.gpg"] + + +def test_baseurl_tolerates_trailing_slash(): + assert ( + _baseurl("ubuntu2404", "stable", STABLE_BASE + "/") + == STABLE_EXPECTED["ubuntu2404"] + ) + + +def test_every_stream_is_per_distro_never_per_format(): + # Both the per-distro and the per-format (/deb/, /rpm/) trees are + # served on every populated stream, so a wrong choice cannot be caught by + # fetching one URL. Pin the per-distro shape: it is what the published + # install instructions configure, and a stream that ever drops the + # per-format alias would break in the field rather than in CI. + for stream, base, sub in ( + ("stable", STABLE_BASE, ""), + ("nightly", NIGHTLY_BASE, NIGHTLY_SUB), + ): + for profile in brp.OS_PROFILES: + url = _baseurl(profile, stream, base, sub) + assert f"/{profile}/" in url, f"{stream} must be per-distro" + assert not url.endswith("/deb/") + assert "/rpm/x86_64/" not in url + + +def test_only_the_build_id_shape_carries_a_sub_folder(): + for profile in brp.OS_PROFILES: + assert NIGHTLY_SUB in _baseurl(profile, "nightly", NIGHTLY_BASE, NIGHTLY_SUB) + assert NIGHTLY_SUB not in _baseurl(profile, "stable", STABLE_BASE) + + +def test_signedness_follows_the_shape(): + # Not an independent axis: the flat streams are the ones publishing + # InRelease/Release.gpg and repomd.xml.asc. + for stream in brp.STREAMS: + assert brp.is_signed(stream) is (brp.stream_shape(stream) == brp._FLAT) + assert brp.is_signed("stable") is True + assert brp.is_signed("nightly") is False + + +def test_unknown_stream_is_rejected_before_it_reaches_a_url(): + with pytest.raises(ValueError): + brp.repo_baseurl(STABLE_BASE, "deb", "ubuntu2404", "bogus", "") + with pytest.raises(ValueError): + brp.repo_id("bogus") + + +def test_a_sub_folder_mismatched_to_the_shape_is_rejected(): + # Dropping a missing sub-folder instead of raising would emit + # // for nightly -- identical in shape to a correct stable URL + # and serving nothing, so the package would install and fail on first + # refresh. parse_args blocks this today; the guard belongs here too, because + # the flat-looking URL is only wrong once it reaches a user. + with pytest.raises(ValueError): + brp.repo_baseurl(NIGHTLY_BASE, "deb", "ubuntu2404", "nightly", "") + with pytest.raises(ValueError): + brp.repo_baseurl(STABLE_BASE, "deb", "ubuntu2404", "stable", NIGHTLY_SUB) + + +# --- repository reachability check -------------------------------------------- + + +def test_repo_metadata_url_targets_the_index(): + deb = brp.repo_metadata_url(STABLE_EXPECTED["ubuntu2404"], "deb") + assert deb == f"{STABLE_EXPECTED['ubuntu2404']}dists/{brp.DEB_SUITE}/Release" + rpm = brp.repo_metadata_url(STABLE_EXPECTED["rhel10"], "rpm") + assert rpm == f"{STABLE_EXPECTED['rhel10']}repodata/repomd.xml" + + +def test_verify_repo_url_accepts_a_served_repo(monkeypatch): + monkeypatch.setattr( + brp.urllib.request, + "urlopen", + lambda *a, **k: _FakeResponse(b"", STABLE_EXPECTED["rhel10"], status=200), + ) + brp.verify_repo_url(STABLE_EXPECTED["rhel10"], "rpm") # must not raise + + +def test_verify_repo_url_rejects_an_unserved_repo(monkeypatch): + # A base that serves no repository 404/403s; the guard must fail the build + # rather than ship a package that cannot refresh. + def not_found(*a, **k): + raise brp.urllib.error.HTTPError( + f"{STABLE_BASE}/rpm/x86_64/", 403, "Forbidden", {}, None + ) + + monkeypatch.setattr(brp.urllib.request, "urlopen", not_found) + monkeypatch.setattr(brp.time, "sleep", lambda s: None) + with pytest.raises(RuntimeError): + brp.verify_repo_url(f"{STABLE_BASE}/rpm/x86_64/", "rpm") + + +def test_verify_repo_url_does_not_retry_a_missing_repo(monkeypatch): + # A wrong URL will not become right, so it must fail on the first attempt + # rather than spending the full retry budget. + calls = {"n": 0} + + def forbidden(*a, **k): + calls["n"] += 1 + raise brp.urllib.error.HTTPError( + f"{STABLE_BASE}/rpm/x86_64/", 403, "Forbidden", {}, None + ) + + monkeypatch.setattr(brp.urllib.request, "urlopen", forbidden) + monkeypatch.setattr(brp.time, "sleep", lambda s: pytest.fail("must not back off")) + with pytest.raises(RuntimeError): + brp.verify_repo_url(f"{STABLE_BASE}/rpm/x86_64/", "rpm") + assert calls["n"] == 1 + + +def test_verify_repo_url_does_not_retry_a_non_200_response(monkeypatch): + # urlopen raises for the error codes, so a status that arrives here is a URL + # that answers but serves no repository index. Retrying cannot change that, + # so it must fail on the first attempt without sleeping. + calls = {"n": 0} + + def created(*a, **k): + calls["n"] += 1 + return _FakeResponse(b"", STABLE_EXPECTED["rhel10"], status=201) + + monkeypatch.setattr(brp.urllib.request, "urlopen", created) + monkeypatch.setattr(brp.time, "sleep", lambda s: pytest.fail("must not back off")) + with pytest.raises(RuntimeError, match="201"): + brp.verify_repo_url(STABLE_EXPECTED["rhel10"], "rpm") + assert calls["n"] == 1 + + +def test_verify_repo_url_retries_rate_limiting(monkeypatch): + calls = {"n": 0} + + def throttled(*a, **k): + calls["n"] += 1 + if calls["n"] < 2: + raise brp.urllib.error.HTTPError( + STABLE_EXPECTED["rhel10"], 429, "Too Many Requests", {}, None + ) + return _FakeResponse(b"", STABLE_EXPECTED["rhel10"], status=200) + + monkeypatch.setattr(brp.urllib.request, "urlopen", throttled) + monkeypatch.setattr(brp.time, "sleep", lambda s: None) + brp.verify_repo_url(STABLE_EXPECTED["rhel10"], "rpm") + assert calls["n"] == 2 + + +def test_verify_repo_url_retries_then_succeeds(monkeypatch): + calls = {"n": 0} + + def flaky(*a, **k): + calls["n"] += 1 + if calls["n"] < 3: + raise brp.urllib.error.URLError("transient") + return _FakeResponse(b"", STABLE_EXPECTED["rhel10"], status=200) + + monkeypatch.setattr(brp.urllib.request, "urlopen", flaky) + monkeypatch.setattr(brp.time, "sleep", lambda s: None) + brp.verify_repo_url(STABLE_EXPECTED["rhel10"], "rpm") + assert calls["n"] == 3 + + +# --- reachability check wiring ------------------------------------------------- +# +# A guard that is never invoked is indistinguishable from no guard, so pin the +# call from main() rather than only testing the function in isolation. + + +def _main_argv(tmp_path, *extra: str) -> list[str]: + return [ + "--os-profile", + "rhel10", + "--stream", + "stable", + "--repo-base-url", + STABLE_BASE, + "--gpg-key-url", + STABLE_KEY_URL, + "--rocm-version", + "10.0.0", + "--dest-dir", + str(tmp_path), + *extra, + ] + + +def test_verify_repo_url_is_off_by_default(): + assert _args().verify_repo_url is False + + +def test_main_verifies_the_repository_when_asked(monkeypatch, tmp_path): + checked = [] + monkeypatch.setattr( + brp, "verify_repo_url", lambda url, pkg_type: checked.append((url, pkg_type)) + ) + monkeypatch.setattr(brp, "build_rpm_package", lambda *a, **k: None) + brp.main(_main_argv(tmp_path, "--verify-repo-url")) + # The URL actually built for this profile is what gets checked. + assert checked == [(STABLE_EXPECTED["rhel10"], "rpm")] + + +def test_main_skips_verification_for_nightly(monkeypatch, tmp_path): + # CI passes the flag unconditionally; the nightly repository is published by + # the same run, so its dated sub-folder cannot be checked at build time. + monkeypatch.setattr( + brp, + "verify_repo_url", + lambda *a: pytest.fail("nightly has nothing to verify yet"), + ) + monkeypatch.setattr(brp, "build_rpm_package", lambda *a, **k: None) + brp.main( + [ + "--os-profile", + "rhel10", + "--stream", + "nightly", + "--repo-base-url", + NIGHTLY_BASE, + "--repo-sub-folder", + NIGHTLY_SUB, + "--rocm-version", + "10.0.0", + "--dest-dir", + str(tmp_path), + "--verify-repo-url", + ] + ) + + +def test_main_does_not_verify_by_default(monkeypatch, tmp_path): + monkeypatch.setattr( + brp, + "verify_repo_url", + lambda *a: pytest.fail("must not reach the network unless asked"), + ) + monkeypatch.setattr(brp, "build_rpm_package", lambda *a, **k: None) + brp.main(_main_argv(tmp_path)) + + +# The other main() tests all use an rpm profile, so without this one the deb +# half of the builder dispatch is never taken and could be deleted outright +# without failing the suite. +@pytest.mark.parametrize( + "os_profile,expected", + [("ubuntu2404", "deb"), ("rhel10", "rpm")], +) +def test_main_dispatches_on_the_profile_package_type( + monkeypatch, tmp_path, os_profile, expected +): + built = [] + monkeypatch.setattr(brp, "build_deb_package", lambda *a, **k: built.append("deb")) + monkeypatch.setattr(brp, "build_rpm_package", lambda *a, **k: built.append("rpm")) + brp.main( + [ + "--os-profile", + os_profile, + "--stream", + "stable", + "--repo-base-url", + STABLE_BASE, + "--gpg-key-url", + STABLE_KEY_URL, + "--rocm-version", + "10.0.0", + "--dest-dir", + str(tmp_path), + ] + ) + assert built == [expected] + + +# --- context wiring ----------------------------------------------------------- + + +@pytest.mark.parametrize("profile", sorted(STABLE_EXPECTED)) +def test_build_context_threads_profile_and_stream(profile): + # The baseurl depends on both the target distro and the stream, so + # build_context must pass both through rather than defaulting either. + args = _args( + "--os-profile", + profile, + "--stream", + "stable", + "--repo-base-url", + STABLE_BASE, + ) + ctx = brp.build_context(args, brp.OS_PROFILES[profile]) + assert ctx["baseurl"] == STABLE_EXPECTED[profile] + + +# --- signing ------------------------------------------------------------------ + + +def test_signed_lines(): + assert brp.is_signed("stable") is True + assert brp.is_signed("stable") is True + assert brp.is_signed("nightly") is False + + +# --- signing-key fetch hardening ---------------------------------------------- + + +class _FakeResponse: + """Minimal stand-in for a urlopen() result (context manager + read).""" + + def __init__(self, body: bytes, url: str, status: int = 200): + self._body = body + self._url = url + self.status = status + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def geturl(self) -> str: + return self._url + + def getcode(self) -> int: + return self.status + + def read(self, n: int = -1) -> bytes: + return self._body if n < 0 else self._body[:n] + + +def test_require_https_accepts_https(): + brp._require_https("https://example.com/gpg/rocm.gpg", "key URL") + + +def test_require_https_rejects_http(): + with pytest.raises(ValueError): + brp._require_https("http://example.com/gpg/rocm.gpg", "key URL") + + +def test_load_signing_key_rejects_non_https_base(monkeypatch): + # A non-https base must fail before any network fetch is attempted. + monkeypatch.setattr( + brp.urllib.request, + "urlopen", + lambda *a, **k: pytest.fail("must not fetch over a non-https URL"), + ) + args = _args() + args.gpg_key_url = "http://rocm.example.com/rocm/gpg/packages.gpg" + with pytest.raises(ValueError): + brp.load_signing_key(args) + + +def test_load_signing_key_rejects_post_redirect_downgrade(monkeypatch): + # An https URL that redirects to http must be rejected after the fetch. + monkeypatch.setattr( + brp.urllib.request, + "urlopen", + lambda *a, **k: _FakeResponse(b"KEY", "http://evil.example/gpg/rocm.gpg"), + ) + with pytest.raises(ValueError): + brp.load_signing_key(_args()) + + +def test_load_signing_key_rejects_oversize_key(monkeypatch): + big = b"A" * (brp.MAX_GPG_KEY_BYTES + 1) + monkeypatch.setattr( + brp.urllib.request, + "urlopen", + lambda *a, **k: _FakeResponse(big, f"{STABLE_BASE}/gpg/rocm.gpg"), + ) + with pytest.raises(ValueError): + brp.load_signing_key(_args()) + + +def test_load_signing_key_embeds_only_the_extracted_key(monkeypatch): + monkeypatch.setattr( + brp.urllib.request, + "urlopen", + lambda *a, **k: _FakeResponse(b"ARMORED KEY", f"{STABLE_BASE}/gpg/rocm.gpg"), + ) + # The pinned-key extraction runs gpg; the real-gpg tests below cover it. + seen = [] + monkeypatch.setattr( + brp, "_extract_pinned_key", lambda key: seen.append(key) or b"PINNED KEY" + ) + assert brp.load_signing_key(_args()) == b"PINNED KEY" + assert seen == [b"ARMORED KEY"] + + +def test_load_signing_key_prefers_key_file(monkeypatch, tmp_path): + key = tmp_path / "rocm.gpg" + key.write_bytes(b"FILE KEY") + monkeypatch.setattr( + brp.urllib.request, + "urlopen", + lambda *a, **k: pytest.fail("must not fetch when --gpg-key-file is given"), + ) + args = _args() + args.gpg_key_file = key + assert brp.load_signing_key(args) == b"FILE KEY" + + +def test_primary_fingerprints_ignores_subkeys(): + listing = "\n".join( + [ + "pub:-:4096:1:AAAA:1:::-:::scESC::::::23::0:", + "fpr:::::::::" + "A" * 40 + ":", + "uid:-::::1::X::Pinned:::::::::0:", + "sub:-:4096:1:BBBB:1::::::s::::::23:", + "fpr:::::::::" + "B" * 40 + ":", + "pub:-:2048:1:CCCC:1:::-:::scESC::::::23::0:", + "fpr:::::::::" + "C" * 40 + ":", + ] + ) + assert brp._primary_fingerprints(listing) == ["A" * 40, "C" * 40] + + +# The pinned-key check runs real gpg on real keys: mocking gpg is what let a +# key file with a second key appended pass the check unnoticed. The builder +# only runs on Linux, so these are skipped on Windows and where gpg is absent. +_GPG = shutil.which("gpg") +_needs_gpg = pytest.mark.skipif( + _GPG is None or sys.platform == "win32", + reason="needs gpg on a Linux host", +) + + +def _gpg(home: Path, *argv: str) -> bytes: + return subprocess.run( + [_GPG, "--homedir", str(home), "--batch", *argv], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ).stdout + + +@pytest.fixture(scope="module") +def signing_keys(tmp_path_factory): + """Two throwaway armored public keys, "pinned" and "extra", by fingerprint.""" + keys = {} + for name in ("pinned", "extra"): + home = tmp_path_factory.mktemp(f"gpg-{name}") + home.chmod(0o700) + _gpg( + home, + "--pinentry-mode", + "loopback", + "--passphrase", + "", + "--quick-generate-key", + f"{name} <{name}@example.invalid>", + "ed25519", + "sign", + "0", + ) + listing = _gpg(home, "--with-colons", "--list-keys").decode() + fingerprint = brp._primary_fingerprints(listing)[0] + keys[name] = (fingerprint, _gpg(home, "--armor", "--export")) + # Key generation starts a gpg-agent for this homedir; stop it. + if shutil.which("gpgconf"): + subprocess.run( + ["gpgconf", "--homedir", str(home), "--kill", "all"], check=False + ) + return keys + + +@_needs_gpg +def test_extract_pinned_key_accepts_the_pinned_key_alone(monkeypatch, signing_keys): + fingerprint, armored = signing_keys["pinned"] + monkeypatch.setattr(brp, "EXPECTED_KEY_FINGERPRINT", fingerprint) + out = brp._extract_pinned_key(armored) + listing = subprocess.run( + [_GPG, "--with-colons", "--show-keys"], + input=out, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ).stdout.decode() + assert brp._primary_fingerprints(listing) == [fingerprint] + + +@_needs_gpg +@pytest.mark.parametrize( + "order", + [("pinned", "extra"), ("extra", "pinned"), ("extra",)], + ids=["pinned+extra", "extra+pinned", "extra"], +) +def test_extract_pinned_key_rejects_any_other_key(monkeypatch, signing_keys, order): + monkeypatch.setattr(brp, "EXPECTED_KEY_FINGERPRINT", signing_keys["pinned"][0]) + armored = b"".join(signing_keys[name][1] for name in order) + with pytest.raises(ValueError): + brp._extract_pinned_key(armored) + + +def test_fetch_signing_key_retries_then_succeeds(monkeypatch): + calls = {"n": 0} + + def flaky(*a, **k): + calls["n"] += 1 + if calls["n"] < 3: + raise brp.urllib.error.URLError("transient") + return _FakeResponse(b"KEY", f"{STABLE_BASE}/gpg/rocm.gpg") + + monkeypatch.setattr(brp.urllib.request, "urlopen", flaky) + monkeypatch.setattr(brp.time, "sleep", lambda s: None) # no real backoff + assert brp._fetch_signing_key(f"{STABLE_BASE}/gpg/rocm.gpg") == b"KEY" + assert calls["n"] == 3 + + +def test_fetch_signing_key_gives_up_after_attempts(monkeypatch): + def always_fail(*a, **k): + raise brp.urllib.error.URLError("down") + + monkeypatch.setattr(brp.urllib.request, "urlopen", always_fail) + monkeypatch.setattr(brp.time, "sleep", lambda s: None) + with pytest.raises(RuntimeError): + brp._fetch_signing_key(f"{STABLE_BASE}/gpg/rocm.gpg") + + +# --- versioning --------------------------------------------------------------- +# +# Every stream takes --rocm-version as given. CI passes the version that +# compute_rocm_package_version.py computes for the stream's release type, which +# is also the version of the ROCm packages in that stream's repository, so the +# stream-level tests below feed it exactly that rather than a hand-typed one. + +CI_ROCM_VERSION = "10.1.0" +CI_DATE = "20261013" + +# The release type whose packages each stream's repository serves, and so the +# one CI computes --rocm-version for. A new stream needs an entry here. +_RELEASE_TYPES = {"stable": "release", "rc": "prerelease", "nightly": "nightly"} + + +def _ci_version(stream, pkg_type, base=CI_ROCM_VERSION, date=CI_DATE, candidate="3"): + """Return the --rocm-version CI passes when it builds ``stream``.""" + release_type = _RELEASE_TYPES[stream] + with mock.patch.object(crpv, "get_current_date", return_value=date): + return crpv.compute_version( + package_type=pkg_type, + release_type=release_type, + prerelease_version=candidate if release_type == "prerelease" else None, + version_data={"rocm-version": base}, + ) + + +def _deb(rocm="10.0.0", suffix="1", profile="ubuntu2404"): + return brp.deb_version(rocm, suffix, profile) + + +def _rpm(stream, rocm="10.0.0", suffix="1", dist=".el8"): + return brp.rpm_version_release(stream, rocm, suffix, dist) + + +def _ci_deb(stream, **kwargs): + """Return the deb version CI builds for ``stream``.""" + return _deb(_ci_version(stream, "deb", **kwargs)) + + +def _ci_rpm(stream, **kwargs): + """Return the rpm Version-Release CI builds for ``stream``.""" + return "-".join(_rpm(stream, _ci_version(stream, "rpm", **kwargs))) + + +def test_ci_passes_the_version_of_the_rocm_packages(): + # The inputs the rest of this section relies on, in the shapes + # docs/packaging/versioning.md documents. If these change, the repo package + # follows them, and the ordering tests below say whether it still sorts the + # way the ROCm packages do. + assert _ci_version("stable", "deb") == "10.1.0" + assert _ci_version("rc", "deb") == "10.1.0~pre3" + assert _ci_version("rc", "rpm") == "10.1.0~rc3" + assert _ci_version("nightly", "deb") == "10.1.0~20261013" + assert _ci_version("nightly", "rpm") == "10.1.0~20261013" + + +@pytest.mark.parametrize( + "stream,deb,rpm", + [ + ("stable", "10.1.0-1~ubuntu2404", "10.1.0-1.stable.el8"), + ("rc", "10.1.0~pre3-1~ubuntu2404", "10.1.0~rc3-1.rc.el8"), + ("nightly", "10.1.0~20261013-1~ubuntu2404", "10.1.0~20261013-1.nightly.el8"), + ], +) +def test_every_stream_carries_the_version_it_is_given(stream, deb, rpm): + # No marker of its own, and nightly's build folder reaches only the URL. + assert _ci_deb(stream) == deb + assert _ci_rpm(stream) == rpm + + +def test_streams_never_share_a_package_version(): + # Installing one stream's package over another's at the same version would + # report success and leave the old repository configured. rpm rules that out + # by carrying the stream in its Release; deb relies on the versions CI + # passes, which differ per release type. + assert len({_ci_deb(s) for s in brp.STREAMS}) == len(brp.STREAMS) + assert len({_ci_rpm(s) for s in brp.STREAMS}) == len(brp.STREAMS) + + +def test_rpm_release_carries_the_stream_whatever_the_version(): + assert len({_rpm(s) for s in brp.STREAMS}) == len(brp.STREAMS) + + +# The profile has to reach the package version, because one package is built per +# profile and their contents differ -- each embeds its own repository URL. +# +# These two tests pass explicit profile names rather than iterating OS_PROFILES. +# OS_PROFILES currently holds exactly one deb profile, so a loop over it would +# compare a one-element set against itself and pass no matter what deb_version +# did with the argument -- including ignoring it. Naming a second debian-family +# profile keeps the assertion honest before that profile is added for real. + + +@pytest.mark.parametrize( + "profile", ["ubuntu2404", "ubuntu2604", "debian12", "debian13"] +) +def test_deb_version_carries_the_os_profile(profile): + assert _deb(profile=profile).endswith(f"~{profile}") + + +def test_deb_profiles_never_share_a_package_version(): + profiles = ["ubuntu2404", "ubuntu2604", "debian12", "debian13"] + assert len({_deb(profile=p) for p in profiles}) == len(profiles) + + +def test_every_rpm_profile_has_a_dist_tag(): + # Asserted as presence-per-profile, not just uniqueness across whatever + # tags happen to exist: dropping a profile's dist_tag would shrink the set + # and leave a uniqueness check passing while reintroducing the collision. + # + # SUSE is the case this protects. rpm's own %{?dist} is empty there, so a + # SUSE profile without an explicit tag gets no distro component at all, and + # sles15 and sles16 would build different packages under one name. + for name, profile in brp.OS_PROFILES.items(): + if profile["pkg_type"] == "rpm": + assert profile.get("dist_tag"), f"{name} has no dist_tag" + else: + # deb carries the profile in the version instead (see deb_version). + assert "dist_tag" not in profile, name + + +def test_rpm_profiles_never_share_a_package_version(): + tags = [p["dist_tag"] for p in brp.OS_PROFILES.values() if p["pkg_type"] == "rpm"] + assert len(tags) == len(set(tags)) + # Include a second SUSE profile: the collision only becomes reachable once + # one exists, and it must be distinguishable before then. + all_tags = tags + [".sles15"] + assert len({_rpm("stable", dist=t) for t in all_tags}) == len(all_tags) + + +def test_version_suffix_reaches_both_package_types(): + # The build number exists so the repository configuration can be re-shipped + # at an unchanged ROCm version. A flag that silently no-opped on one format + # would produce a duplicate package name there. + assert _deb(suffix="2") == "10.0.0-2~ubuntu2404" + for stream in brp.STREAMS: + assert _rpm(stream, suffix="2")[1] == f"2.{stream}.el8" + + +def test_rc_version_uses_the_stream_not_the_file_id(): + # The repo file stem is amdrocm-stablerc (see STREAM_IDS); neither version + # may carry it. + assert "stablerc" not in _ci_deb("rc") + assert "stablerc" not in _ci_rpm("rc") + assert brp.repo_id("rc") == "amdrocm-stablerc" + + +def test_rc_alternate_names_are_only_the_file_id(): + # The version comes from CI, so rc needs no marker of its own. + assert brp.STREAM_IDS["rc"] == {"file_id": "stablerc"} + + +def test_a_tilde_bearing_rocm_version_composes(): + # CI passes versions such as 10.1.0~pre3 and 7.14.0~dev20260811. The revision + # is appended after them, so the result carries two "~" and must still + # parse. + assert _deb("7.14.0~dev20260811") == "7.14.0~dev20260811-1~ubuntu2404" + + +# Ordering is asserted against dpkg and rpm themselves rather than a hand-rolled +# comparison: the rules for "~" and for mixed alphanumeric segments are exactly +# what a reimplementation would get wrong, and getting them wrong is what turns +# an upgrade into a silent downgrade. +_DPKG = shutil.which("dpkg") +_RPM = shutil.which("rpm") + + +def _deb_cmp(a: str, b: str) -> int: + """Compare two deb versions with dpkg: -1, 0 or 1.""" + # Three-argument form. "dpkg --compare-versions a b" is a usage error, not a + # comparison, and reports failure for every pair. + for op, result in (("lt", -1), ("eq", 0)): + cmd = [_DPKG, "--compare-versions", a, op, b] + if subprocess.run(cmd, capture_output=True).returncode == 0: + return result + return 1 + + +def _rpm_cmp(a: str, b: str) -> int: + """Compare two rpm Version-Release strings with rpm: -1, 0 or 1.""" + expr = f"%{{lua: print(rpm.vercmp('{a}', '{b}'))}}" + out = subprocess.run( + [_RPM, "--eval", expr], capture_output=True, text=True, check=True + ) + return int(out.stdout) + + +def _rpm_has_vercmp() -> bool: + try: + return _RPM is not None and _rpm_cmp("1", "2") == -1 + except (subprocess.CalledProcessError, ValueError): + return False + + +_needs_dpkg = pytest.mark.skipif(_DPKG is None, reason="dpkg is not available") +_needs_rpm = pytest.mark.skipif( + not _rpm_has_vercmp(), reason="rpm with rpm.vercmp is not available" +) + +# Pairs of builds, each (stream, overrides for _ci_version): every two streams at +# one ROCm version, and the steps a user upgrades through. None is a tie, so +# each pair has an order to compare. +_CI_PAIRS = [ + pytest.param(("stable", {}), ("rc", {}), id="stable-rc"), + pytest.param(("stable", {}), ("nightly", {}), id="stable-nightly"), + pytest.param(("rc", {}), ("nightly", {}), id="rc-nightly"), + pytest.param(("rc", {}), ("rc", {"candidate": "4"}), id="next-candidate"), + pytest.param(("nightly", {}), ("nightly", {"date": "20261014"}), id="next-nightly"), + pytest.param(("stable", {}), ("nightly", {"base": "10.2.0"}), id="next-release"), +] + + +@_needs_dpkg +@pytest.mark.parametrize("a,b", _CI_PAIRS) +def test_deb_repo_packages_sort_like_their_rocm_packages(a, b): + (stream_a, over_a), (stream_b, over_b) = a, b + rocm = _deb_cmp( + _ci_version(stream_a, "deb", **over_a), _ci_version(stream_b, "deb", **over_b) + ) + assert rocm != 0 + assert _deb_cmp(_ci_deb(stream_a, **over_a), _ci_deb(stream_b, **over_b)) == rocm + + +@_needs_rpm +@pytest.mark.parametrize("a,b", _CI_PAIRS) +def test_rpm_repo_packages_sort_like_their_rocm_packages(a, b): + (stream_a, over_a), (stream_b, over_b) = a, b + rocm = _rpm_cmp( + _ci_version(stream_a, "rpm", **over_a), _ci_version(stream_b, "rpm", **over_b) + ) + assert rocm != 0 + assert _rpm_cmp(_ci_rpm(stream_a, **over_a), _ci_rpm(stream_b, **over_b)) == rocm + + +@_needs_dpkg +@pytest.mark.parametrize("stream", ["rc", "nightly"]) +def test_deb_stable_outranks_the_other_streams_at_its_rocm_version(stream): + assert _deb_cmp(_ci_deb(stream), _ci_deb("stable")) == -1 + + +@_needs_rpm +@pytest.mark.parametrize("stream", ["rc", "nightly"]) +def test_rpm_stable_outranks_the_other_streams_at_its_rocm_version(stream): + assert _rpm_cmp(_ci_rpm(stream), _ci_rpm("stable")) == -1 + + +@_needs_dpkg +@pytest.mark.parametrize( + "lower,higher,why", + [ + ({"suffix": "1"}, {"suffix": "2"}, "respin bump"), + ({"rocm": "10.0.0"}, {"rocm": "10.0.1"}, "ROCm version bump"), + ({"profile": "ubuntu2204"}, {"profile": "ubuntu2404"}, "newer Ubuntu"), + # The reason profile names are used instead of Debian codenames: as + # codenames this pair inverts, because "bullseye" > "bookworm" + # alphabetically while Debian 11 < Debian 12. + ({"profile": "debian11"}, {"profile": "debian12"}, "newer Debian"), + ], +) +def test_deb_versions_sort_in_release_order(lower, higher, why): + assert _deb_cmp(_deb(**lower), _deb(**higher)) == -1, why + + +# The rpm %changelog date must stay English on any build machine. strftime and +# calendar.day_abbr both follow the process locale, so the names are pinned in +# module constants; these two tests are what keep anyone from reverting to +# either of those. + + +def test_rpm_changelog_date_reads_the_pinned_names(monkeypatch): + # Runs everywhere, unlike the locale test below. Swapping the constants for + # sentinels proves the value comes from them and not from the C library: a + # strftime-based implementation would ignore the patch and still say "Thu". + monkeypatch.setattr(brp, "_CHANGELOG_DAY_ABBR", ("D0",) * 7) + monkeypatch.setattr(brp, "_CHANGELOG_MONTH_ABBR", ("M0",) * 12) + assert ( + brp.rpm_changelog_date(datetime(2026, 7, 30, tzinfo=timezone.utc)) + == "D0 M0 30 2026" + ) + + +def test_rpm_changelog_date_is_english_under_a_foreign_locale(): + # The real end-to-end check, but it only runs where a German locale has been + # generated. CI has not: a stock ubuntu image carries C, C.utf8 and POSIX + # only. Under C the pinned and strftime implementations agree, which is why + # the sentinel test above carries the actual regression guard. + saved = locale.setlocale(locale.LC_ALL) + try: + try: + locale.setlocale(locale.LC_ALL, "de_DE.UTF-8") + except locale.Error: + pytest.skip("de_DE.UTF-8 not generated on this machine") + assert ( + brp.rpm_changelog_date(datetime(2026, 7, 30, tzinfo=timezone.utc)) + == "Thu Jul 30 2026" + ) + finally: + locale.setlocale(locale.LC_ALL, saved) + + +# --- CLI validation ----------------------------------------------------------- + + +def test_stream_rejects_unknown(): + with pytest.raises(SystemExit): + _args("--stream", "dev") + + +def test_repo_base_url_required(): + with pytest.raises(SystemExit): + brp.parse_args( + [ + "--os-profile", + "ubuntu2404", + "--stream", + "stable", + "--rocm-version", + "10.0.0", + "--dest-dir", + "/tmp/out", + ] + ) + + +def test_nightly_requires_sub_folder(): + with pytest.raises(SystemExit): + _args("--stream", "nightly", "--repo-base-url", NIGHTLY_BASE) + + +def test_nightly_with_sub_folder_ok(): + args = _args( + "--stream", + "nightly", + "--repo-base-url", + NIGHTLY_BASE, + "--repo-sub-folder", + NIGHTLY_SUB, + ) + assert args.repo_sub_folder == NIGHTLY_SUB + + +def test_sub_folder_rejected_for_non_nightly(): + with pytest.raises(SystemExit): + _args("--repo-sub-folder", NIGHTLY_SUB) # default stream is stable (flat) + + +# --- input validation --------------------------------------------------------- + + +@pytest.mark.parametrize( + "bad_version", + [ + '10.0.0"; rm -rf /; echo "', # shell-injection payload + "10.0.0 evil", # whitespace + "10.0.0$(id)", # command substitution + "10.0.0\ngpgcheck=0", # embedded newline + "10.0.0\n", # trailing newline (regex must use \\Z, not $) + "", # empty + "-10.0.0", # must start with a digit + # An rpm Version cannot contain a hyphen: rpm reads it as the boundary + # between version and release. rpm_version_release() passes this value + # straight into the spec's Version: field, so accepting one here would + # produce a spec rpmbuild rejects. "~rc1" is the form to use instead. + "10.0.0-rc1", + ], +) +def test_rocm_version_rejects_bad_format(bad_version): + with pytest.raises(SystemExit): + _args("--rocm-version", bad_version) + + +@pytest.mark.parametrize("good_version", ["10.0.0", "10.0.0~rc3", "8.0.1rc1", "7.0"]) +def test_rocm_version_accepts_valid(good_version): + assert _args("--rocm-version", good_version).rocm_version == good_version + + +def test_version_suffix_defaults_to_one(): + assert _args().version_suffix == "1" + + +@pytest.mark.parametrize( + "bad_suffix", + [ + # deb_version joins the suffix to the upstream version with the single + # "-" that separates a debian revision, and rpm forbids one in Release. + "1-2", + # deb_version appends "~" after the suffix. A "~" inside it + # sorts before everything and would reorder the two parts. + "1~x", + "", # empty + "x1", # must start with a digit + "1 2", # whitespace + "1\n", # trailing newline (regex must use \\Z, not $) + ], +) +def test_version_suffix_rejects_bad_format(bad_suffix): + with pytest.raises(SystemExit): + _args("--version-suffix", bad_suffix) + + +@pytest.mark.parametrize("good_suffix", ["1", "2", "1.1", "10", "1+deb"]) +def test_version_suffix_accepts_valid(good_suffix): + assert _args("--version-suffix", good_suffix).version_suffix == good_suffix + + +@pytest.mark.parametrize( + "bad_url", + [ + f"{STABLE_BASE}\ngpgcheck=0", # newline directive injection + f"{STABLE_BASE} extra", # whitespace + "not a url", # no scheme/netloc + "ftp://example.com/repo", # unsupported scheme + ], +) +def test_repo_base_url_rejects_bad(bad_url): + with pytest.raises(SystemExit): + _args("--repo-base-url", bad_url) + + +def test_repo_base_url_accepts_https(): + assert _args("--repo-base-url", STABLE_BASE).repo_base_url == STABLE_BASE + + +def test_repo_sub_folder_rejects_bad_format(): + with pytest.raises(SystemExit): + _args( + "--stream", + "nightly", + "--repo-base-url", + NIGHTLY_BASE, + "--repo-sub-folder", + "20260722-99\nX", # newline in the dated sub-folder + ) + + +@pytest.mark.parametrize("sub", ["20260722-99-1", "20260722-run-42"]) +def test_repo_sub_folder_rejects_a_second_hyphen(sub): + # Published build folders are -; anything else names a + # folder that does not exist. + with pytest.raises(SystemExit): + _args( + "--stream", + "nightly", + "--repo-base-url", + NIGHTLY_BASE, + "--repo-sub-folder", + sub, + ) + + +def test_rpm_release_never_contains_a_hyphen(): + # rpmbuild rejects a "-" in Release ("Illegal char '-' (0x2d)"), and + # --version-suffix is validated against one, so every stream must produce a + # Release it accepts. + for stream in brp.STREAMS: + assert "-" not in _rpm(stream)[1] + # The dist tags must not reintroduce one. + for profile in brp.OS_PROFILES.values(): + assert "-" not in profile.get("dist_tag", "") + + +def test_postinst_does_not_interpolate_version(): + # The postinst runs as root at install time; the version must not be rendered + # into it (defense-in-depth beyond input validation). + ctx = brp.build_context( + _args("--rocm-version", "9.9.9"), brp.OS_PROFILES["ubuntu2404"] + ) + out = _render("template/repo/deb/postinst.j2", ctx) + assert "9.9.9" not in out + assert "AMD ROCm repository configured." in out + + +# --- rpm .repo rendering ------------------------------------------------------ + + +def _rpm_repo(stream: str, base: str, sub: str = "", profile: str = "rhel10") -> str: + extra = [ + "--os-profile", + profile, + "--stream", + stream, + "--repo-base-url", + base, + ] + if sub: + extra += ["--repo-sub-folder", sub] + args = _args(*extra) + return _render( + "template/repo/rpm/amdrocm.repo.j2", + brp.build_context(args, brp.OS_PROFILES[profile]), + ) + + +def test_rpm_repo_signed_has_gpgcheck_and_gpgkey(): + out = _rpm_repo("stable", STABLE_BASE) + assert "[amdrocm-stable]" in out + assert "enabled=1" in out + assert "gpgcheck=1" in out + assert "gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-amdrocm" in out + assert f"baseurl={STABLE_EXPECTED['rhel10']}" in out + + +def test_rpm_repo_nightly_unsigned_no_gpgkey(): + out = _rpm_repo("nightly", NIGHTLY_BASE, NIGHTLY_SUB) + assert "gpgcheck=0" in out + assert "gpgkey" not in out + + +def test_rpm_repo_ends_with_newline(): + assert _rpm_repo("stable", STABLE_BASE).endswith("\n") + + +# --- deb .sources rendering --------------------------------------------------- + + +def _deb_sources(stream: str, base: str, sub: str = "") -> str: + extra = [ + "--os-profile", + "ubuntu2404", + "--stream", + stream, + "--repo-base-url", + base, + ] + if sub: + extra += ["--repo-sub-folder", sub] + args = _args(*extra) + return _render( + "template/repo/deb/amdrocm.sources.j2", + brp.build_context(args, brp.OS_PROFILES["ubuntu2404"]), + ) + + +def test_deb_sources_signed_fields(): + out = _deb_sources("stable", STABLE_BASE) + assert "X-Repo-Id: amdrocm-stable" in out + assert "Types: deb" in out + assert f"URIs: {STABLE_EXPECTED['ubuntu2404']}" in out + # Suites is literal in the template; it must equal DEB_SUITE, the suite + # verify_repo_url fetches, or the checked index is not the one apt reads. + assert f"Suites: {brp.DEB_SUITE}" in out + assert "Suites: stable" in out + assert "Components: main" in out + assert "Architectures: amd64" in out + assert f"Signed-By: {brp.DEB_KEYRING_PATH}" in out + # Pinned literally as well, so the package-specific keyring name cannot + # drift back to a generic one. + assert "Signed-By: /usr/share/keyrings/amdrocm.gpg" in out + assert "Trusted:" not in out + + +def test_deb_sources_nightly_trusted_not_signed(): + out = _deb_sources("nightly", NIGHTLY_BASE, NIGHTLY_SUB) + assert "Trusted: yes" in out + assert "Signed-By:" not in out + + +# --- rpm spec rendering ------------------------------------------------------- + + +def _spec(stream: str, base: str, sub: str = "") -> str: + extra = [ + "--os-profile", + "rhel10", + "--stream", + stream, + "--repo-base-url", + base, + ] + if sub: + extra += ["--repo-sub-folder", sub] + args = _args(*extra) + ctx = brp.build_context(args, brp.OS_PROFILES["rhel10"]) + version, release = brp.rpm_version_release( + stream, args.rocm_version, args.version_suffix, ctx["dist_tag"] + ) + return _render( + "template/repo/rpm/amdrocm-repo.spec.j2", + { + **ctx, + "version": version, + "release": release, + "changelog_date": "Mon Jul 20 2026", + }, + ) + + +def test_spec_name_and_repo_install(): + out = _spec("stable", STABLE_BASE) + assert "Name: amdrocm-repo" in out + assert "Version: 10.0.0" in out + assert "/etc/yum.repos.d/amdrocm-stable.repo" in out + + +@pytest.mark.parametrize( + "stream,sub,expected", + [ + ("stable", "", "Release: 1.stable.el10"), + ("nightly", NIGHTLY_SUB, "Release: 1.nightly.el10"), + ], +) +def test_spec_release_line_is_exact(stream, sub, expected): + # Asserted as the whole rendered line, not a substring. rpm_version_release + # already appends the dist tag, so a template that appended it again would + # still contain ".el10" and pass a substring check while emitting + # "1.stable.el10.el10" -- which is what a build actually produced before + # this test existed. + base = _base_for(stream) + lines = _spec(stream, base, sub).splitlines() + assert expected in lines, [line for line in lines if line.startswith("Release:")] + + +def test_spec_signed_installs_key_nightly_does_not(): + signed = _spec("stable", STABLE_BASE) + assert "/etc/pki/rpm-gpg/RPM-GPG-KEY-amdrocm" in signed + unsigned = _spec("nightly", NIGHTLY_BASE, NIGHTLY_SUB) + assert "RPM-GPG-KEY-amdrocm" not in unsigned + + +@pytest.mark.parametrize( + "stream,sub", + ALL_STREAMS, +) +def test_spec_conflicts_with_the_legacy_installer_on_every_stream(stream, sub): + # amdgpu-install ships its own enabled ROCm repository, so the two packages + # must not be co-installed. Their repo files are named differently from + # ours, so nothing stops rpm accepting both -- this declaration is the only + # thing that makes the overlap visible. Asserted on every stream + # because that package configures a ROCm repository whichever stream we + # point at. + base = _base_for(stream) + assert f"Conflicts: {brp.LEGACY_INSTALLER_PACKAGE}" in _spec(stream, base, sub) + + +@pytest.mark.parametrize( + "stream,sub", + ALL_STREAMS, +) +def test_spec_install_scriptlet_only_prints(stream, sub): + # The package ships the key as a file and points gpgkey= at it; it must not + # try to import it into the rpm keyring from a scriptlet. + # + # This looks like an easy improvement -- zypper otherwise stops to ask + # before its first refresh, and --non-interactive answers "reject", so the + # repository is skipped and nothing installs. It does not work: rpm holds + # the database lock for the whole transaction, so "rpm --import" fails with + # "can't create transaction lock" from %post and from %posttrans alike, + # and the failure is only a scriptlet warning, so the package still + # installs looking healthy. + # + # The supported answer is the package manager's own flag -- "dnf -y" or + # "zypper --gpg-auto-import-keys" -- which is what the published ROCm + # install instructions use. See docs/packaging/rocm_repo_setup.md. + # + # The scriptlet exists to print the same greeting the deb postinst does, so + # this asserts on its contents rather than on its absence. An empty-scriptlet + # check would have been the stronger guard while there was no scriptlet at + # all; now that one exists, the trap is someone extending it. + base = _base_for(stream) + out = _spec(stream, base, sub) + + def _statements(text: str) -> list[str]: + """Non-comment, non-blank lines -- what the spec actually executes.""" + return [ + line.strip() + for line in text.splitlines() + if line.strip() and not line.strip().startswith("#") + ] + + # Across the whole spec, so a scriptlet added later -- %pre, %posttrans -- + # is covered too, not just the %post below. Comments are excluded because + # the spec documents this hazard by name. + assert not [s for s in _statements(out) if "rpm --import" in s] + + body = out.split("\n%post\n", 1)[1].split("\n%files", 1)[0] + statements = _statements(body) + assert statements, "expected the %post scriptlet to print something" + # Messages only: nothing that could reach the rpm database or the network. + assert all(s.startswith("echo ") for s in statements), statements + + +def test_spec_files_sets_root_ownership_before_listing_anything(): + # %defattr only governs the entries that follow it, so position is the point + # and a bare "is it present" assertion would not catch a misplaced one. + out = _spec("stable", STABLE_BASE) + body = out.split("%files", 1)[1].split("%changelog", 1)[0] + entries = [ln.strip() for ln in body.splitlines() if ln.strip()] + assert entries[0] == "%defattr(-, root, root, -)" + assert any(ln.startswith("%config(noreplace)") for ln in entries[1:]) + + +# --- deb control / install rendering ------------------------------------------ + + +def test_control_has_no_key_runtime_deps(): + args = _args() + ctx = brp.build_context(args, brp.OS_PROFILES["ubuntu2404"]) + out = _render("template/repo/deb/control.j2", ctx) + assert "Package: amdrocm-repo" in out + assert "gnupg" not in out + assert "wget" not in out + + +@pytest.mark.parametrize("stream", sorted(brp.STREAMS)) +def test_control_conflicts_with_the_legacy_installer_on_every_stream(stream): + # Conflicts only, not Conflicts + Breaks. Conflicts is the stronger field -- + # it blocks unpacking, where Breaks only blocks configuration -- and adding + # Breaks alongside it changes nothing: dpkg and apt behave identically with + # either field alone or both. Breaks would also imply some + # version of amdgpu-install exists that is not affected, which is not the + # case. Declared for every stream, since that package configures a + # ROCm repository regardless of which stream this one points at. + extra = ["--stream", stream] + if stream == "nightly": + extra += ["--repo-base-url", NIGHTLY_BASE, "--repo-sub-folder", NIGHTLY_SUB] + ctx = brp.build_context(_args(*extra), brp.OS_PROFILES["ubuntu2404"]) + out = _render("template/repo/deb/control.j2", ctx) + assert f"Conflicts: {brp.LEGACY_INSTALLER_PACKAGE}" in out + assert "Breaks:" not in out + # The field belongs to the binary package, not the source stanza; a + # Conflicts above the "Package:" line would silently do nothing. + binary_stanza = out.split("Package: ", 1)[1] + assert "Conflicts:" in binary_stanza + + +def test_postinst_warns_only_when_the_legacy_installer_is_unpurged(): + # apt resolves the Conflicts by removing amdgpu-install, whose repo file + # and priority pin are conffiles and survive. The probe keys on that exact + # dpkg state rather than scanning apt's configuration: a scan cannot tell + # a leftover ROCm repository from the AMDGPU driver repository the same + # vendor ships, and would tell the user to remove one they still need. + out = _render( + "template/repo/deb/postinst.j2", + brp.build_context(_args(), brp.OS_PROFILES["ubuntu2404"]), + ) + assert f"dpkg-query -W -f='${{Status}}' {brp.LEGACY_INSTALLER_PACKAGE}" in out + assert "deinstall ok config-files" in out + assert f"apt purge {brp.LEGACY_INSTALLER_PACKAGE}" in out + # The probe must not name individual apt configuration files, which is + # what made the previous wording point at the driver repository. + assert "/etc/apt/sources.list.d" not in out + assert "/etc/apt/preferences.d" not in out + + +def test_postinst_cannot_fail_the_install(): + # postinst runs under "set -e" and before #DEBHELPER#, so any command that + # can report non-zero would abort the package install. dpkg-query exits + # non-zero for an unknown package, which is the ordinary case on a system + # that never had the legacy installer, so it must be guarded. + out = _render( + "template/repo/deb/postinst.j2", + brp.build_context(_args(), brp.OS_PROFILES["ubuntu2404"]), + ) + assert "set -e" in out + assert "dpkg-query" in out, "expected the legacy-installer probe" + # The guard may sit on a continuation line, so check the whole command. + probe = out.split("legacy_state=$(", 1)[1].split(")", 1)[0] + assert "|| true" in probe + + +def test_rules_forces_root_ownership_in_the_archive(): + # The archive is root:root either way (fakeroot without Rules-Requires-Root; + # dh_builddeb adds the flag itself with "Rules-Requires-Root: no"). The + # override states it explicitly to match template/debian_rules.j2. + out = _render( + "template/repo/deb/rules.j2", + brp.build_context(_args(), brp.OS_PROFILES["ubuntu2404"]), + ) + assert "override_dh_builddeb:" in out + assert "--root-owner-group" in out + # The flag has to reach dpkg-deb itself, past the "--" separator, or + # dh_builddeb treats it as one of its own options and fails. + assert "dh_builddeb -- -Zxz --root-owner-group" in out + + +def test_install_maps_keyring_only_when_signed(): + signed = brp.build_context(_args(), brp.OS_PROFILES["ubuntu2404"]) + out_signed = _render("template/repo/deb/install.j2", signed) + assert "amdrocm-stable.sources /etc/apt/sources.list.d/" in out_signed + assert "amdrocm.gpg /usr/share/keyrings/" in out_signed + # The mapped destination must be exactly where Signed-By points, or apt + # silently treats the repository as unsigned. PurePosixPath, not Path: on + # Windows the local flavour would give both sides "\usr\share\keyrings" and + # this would agree with a rendered value that apt could never use. + keyring = PurePosixPath(brp.DEB_KEYRING_PATH) + assert f"{keyring.name} {keyring.parent}/" in out_signed + + args_n = _args( + "--stream", + "nightly", + "--repo-base-url", + NIGHTLY_BASE, + "--repo-sub-folder", + NIGHTLY_SUB, + ) + out_nightly = _render( + "template/repo/deb/install.j2", + brp.build_context(args_n, brp.OS_PROFILES["ubuntu2404"]), + ) + # No keyring file or directory is mapped for an unsigned stream. + assert "amdrocm.gpg" not in out_nightly + assert "/usr/share/keyrings/" not in out_nightly + + +# --- list-profiles -------------------------------------------------------- + + +def test_list_profiles_rpm(): + assert brp.list_profiles("rpm") == ["rhel8", "rhel10", "sles16"] + + +def test_list_profiles_deb(): + assert brp.list_profiles("deb") == ["ubuntu2404"] + + +def test_list_profiles_cli_emits_json(capsys): + brp.run_list_profiles(["--pkg-type", "rpm"]) + out = capsys.readouterr().out + assert json.loads(out) == ["rhel8", "rhel10", "sles16"] + + +def test_list_profiles_cli_rejects_invalid_pkg_type(capsys): + with pytest.raises(SystemExit): + brp.run_list_profiles(["--pkg-type", "foo"]) + + +def test_main_dispatches_list_profiles(capsys): + brp.main(["list-profiles", "--pkg-type", "deb"]) + out = capsys.readouterr().out + assert json.loads(out) == ["ubuntu2404"] + + +# --- dest-dir normalization ----------------------------------------------- + + +def test_relative_dest_dir_is_resolved_to_absolute(): + # rpmbuild needs an absolute _topdir, so a relative --dest-dir must be + # normalized to absolute at parse time. + args = brp.parse_args( + [ + "--os-profile", + "rhel10", + "--stream", + "stable", + "--repo-base-url", + STABLE_BASE, + "--gpg-key-url", + STABLE_KEY_URL, + "--rocm-version", + "10.0.0", + "--dest-dir", + "relout", + ] + ) + assert args.dest_dir.is_absolute() + assert args.dest_dir.name == "relout" + + +def test_absolute_dest_dir_stays_absolute(): + args = _args("--dest-dir", "/tmp/somewhere") + assert args.dest_dir.is_absolute() + assert args.dest_dir.name == "somewhere" + + +# --- installed-file naming ---------------------------------------------------- +# +# The repo file's stem and its rpm section id both come from repo_id(), so the +# stream name reaches disk through a single value. Two streams' packages can be +# installed in turn, and if they wrote the same filename the second would +# silently replace the first's configuration. + + +# The expected repo-file stem for every stream this builds (see STREAM_IDS in +# the builder). Written out rather than derived, because deriving it from the +# stream name would reproduce a mapping bug instead of catching it -- and rc is +# exactly the case where the two differ: RFC0012 names its tier package +# amdrocm-repo-stablerc, so the stem is "stablerc" while the subdomain is "rc". +EXPECTED_REPO_FILE_STEMS = { + "stable": "amdrocm-stable", + "rc": "amdrocm-stablerc", + "nightly": "amdrocm-nightly", +} + + +@pytest.mark.parametrize("stream", sorted(brp.STREAMS)) +def test_installed_filename_matches_the_expected_stem(stream): + # The eventual split into per-tier packages is only cheap if the installed + # file stems are already right, so these are asserted literally, not derived. + assert stream in EXPECTED_REPO_FILE_STEMS, f"no stem recorded for {stream!r}" + assert brp.repo_id(stream) == EXPECTED_REPO_FILE_STEMS[stream] + + +def test_no_two_streams_share_an_installed_filename(): + ids = {brp.repo_id(s) for s in brp.STREAMS} + assert len(ids) == len(brp.STREAMS) + + +@pytest.mark.parametrize("stream,sub", ALL_STREAMS) +def test_rpm_section_id_matches_the_repo_file_stem(stream, sub): + # Assert the whole header, not a substring: "[amdrocm-stable]" contains + # "amdrocm", so a containment check would pass on the unscoped id and prove + # nothing. Same trap as the amdrocm.gpg / rocm.gpg keyring rename. + base = _base_for(stream) + out = _rpm_repo(stream, base, sub) + assert f"[{brp.repo_id(stream)}]" in out + assert "[amdrocm]\n" not in out + + +def test_deb_install_maps_the_stream_scoped_sources_file(): + # debian/install maps the staged file to /etc/apt/sources.list.d, so the + # stem here is the name that lands on the user's disk. + args = _args() + ctx = brp.build_context(args, brp.OS_PROFILES["ubuntu2404"]) + out = _render("template/repo/deb/install.j2", ctx) + assert out.startswith("amdrocm-stable.sources /etc/apt/sources.list.d/") + + +# --- parity with the published install instructions --------------------------- +# +# These stanzas are copied verbatim from the ROCm 10.0.0 installation +# instructions (ROCm/rocm-docs, docs/install/include/200-install.rst at +# 44d9ab17), which are what users are told to run. They are the closest thing +# to an external oracle available here: a golden file we wrote ourselves only +# proves the renderer is self-consistent, whereas these prove it agrees with the +# configuration AMD actually publishes for the same repositories. +# +# Vendored rather than read from that repository, which is not a dependency of +# TheRock. Refresh them by hand when the documented configuration changes -- a +# mismatch here is the signal that it did. + +DOCUMENTED_DEB_UBUNTU2404 = """\ +X-Repo-Id: amdrocm-stable +Types: deb +URIs: https://stable.repo.amd.com/rocm/core/packages/ubuntu2404/ +Suites: stable +Components: main +Architectures: amd64 +Signed-By: /etc/apt/keyrings/amdrocm.gpg +Enabled: yes +""" + +# The documented rpm stanzas differ only by the distro slug, so one template +# covers all three rpm profiles we build. +DOCUMENTED_RPM = """\ +[amdrocm-stable] +name=ROCm 10.0.0 +baseurl=https://stable.repo.amd.com/rocm/core/packages/{slug}/x86_64 +enabled=1 +gpgcheck=1 +gpgkey=https://stable.repo.amd.com/rocm/gpg/packages.gpg +""" + +# Fields where a package must diverge from an inline setup script, each with the +# reason. Anything NOT listed here has to match, and a new entry is a decision +# that belongs in the PR description rather than a quiet edit to this table. +# +# Signed-By the documented steps download the key to /etc/apt/keyrings, +# the location for locally managed keys; the package owns its +# keyring, so it ships it under /usr/share/keyrings. +# gpgkey the package ships the key, so it references it locally with +# file:// rather than refetching it over the network at install. +# baseurl trailing slash; both forms resolve, cosmetic only. +# name the documented stanza bakes in the version ("ROCm 10.0.0") +# because it is written per release. This package configures a +# rolling repo that serves every retained version, so a version +# in the display name would be wrong once the next one ships. +DEVIATIONS = {"Signed-By", "gpgkey", "baseurl", "name"} + + +def _fields(text: str, sep: str) -> dict: + out = {} + for line in text.strip().splitlines(): + if sep in line and not line.startswith("["): + k, _, v = line.partition(sep) + out[k.strip()] = v.strip() + return out + + +def test_deb_sources_match_the_published_install_instructions(): + ours = _fields(_deb_sources("stable", STABLE_BASE), ":") + theirs = _fields(DOCUMENTED_DEB_UBUNTU2404, ":") + assert set(ours) == set(theirs), "field set diverged from the published config" + for key in theirs: + if key in DEVIATIONS: + continue + assert ours[key] == theirs[key], f"{key} diverged from the published config" + + +def test_deb_deviations_are_the_expected_ones(): + # Assert the deviations too, so a silent change to one shows up here rather + # than being waved through by the skip list above. + ours = _fields(_deb_sources("stable", STABLE_BASE), ":") + assert ours["Signed-By"] == "/usr/share/keyrings/amdrocm.gpg" + + +def test_rpm_name_is_not_version_pinned(): + # The repo serves every retained version, so its display name must not name + # one. Guards the "name" deviation above against being quietly reverted to + # match the documented stanza. + ours = _fields(_rpm_repo("stable", STABLE_BASE), "=") + assert ours["name"] == brp.REPO_NAME + assert "10.0.0" not in ours["name"] + + +@pytest.mark.parametrize("profile", ["rhel8", "rhel10", "sles16"]) +def test_rpm_repo_matches_the_published_install_instructions(profile): + ours = _fields(_rpm_repo("stable", STABLE_BASE, profile=profile), "=") + theirs = _fields(DOCUMENTED_RPM.format(slug=profile), "=") + assert set(ours) == set(theirs), "field set diverged from the published config" + for key in theirs: + if key in DEVIATIONS: + continue + assert ours[key] == theirs[key], f"{key} diverged from the published config" + # baseurl differs only by the trailing slash. + assert ours["baseurl"].rstrip("/") == theirs["baseurl"].rstrip("/") + # gpgkey is local because the package ships the key. + assert ours["gpgkey"] == f"file://{brp.RPM_GPG_KEY_PATH}" + + +@pytest.mark.parametrize( + "profile,expected_dir", + [ + ("rhel8", "/etc/yum.repos.d"), + ("rhel10", "/etc/yum.repos.d"), + ("sles16", "/etc/zypp/repos.d"), + ], +) +def test_rpm_repo_dir_matches_the_published_install_instructions(profile, expected_dir): + # The documented steps write SLES to /etc/zypp/repos.d and the RHEL family + # to /etc/yum.repos.d. Easy to get wrong and invisible until install time. + assert brp.OS_PROFILES[profile]["rpm_repo_dir"] == expected_dir diff --git a/docs/packaging/native_packaging.md b/docs/packaging/native_packaging.md index c00ed49a746..4c93c6b3d88 100644 --- a/docs/packaging/native_packaging.md +++ b/docs/packaging/native_packaging.md @@ -6,6 +6,9 @@ distribution as Debian and RPM packages. This process involves both the reorganization of artifacts and the execution of package management tools +To configure a system package manager to install ROCm from a public repository, +see [rocm_repo_setup.md](rocm_repo_setup.md). + ## General Design The required packages and their details are stored in a JSON file diff --git a/docs/packaging/rocm_repo_setup.md b/docs/packaging/rocm_repo_setup.md new file mode 100644 index 00000000000..3a0586a0d9b --- /dev/null +++ b/docs/packaging/rocm_repo_setup.md @@ -0,0 +1,421 @@ +# Configuring the ROCm package repository (`amdrocm-repo`) + +`amdrocm-repo` is a configuration package that points a system package manager +(`apt`, `dnf`, or `zypper`) at a public AMD ROCm repository. Once it is +installed, you can install and update ROCm packages with the native package +manager. + +It configures the repository only. The ROCm *content* packages and their upload +paths are documented in [native_packaging.md](native_packaging.md). + +## What the package installs + +`amdrocm-repo` is generated per OS profile and per stream. Every profile writes a +single repo definition and, for signed streams, the repository signing key: + +| OS profile | Package manager | Repo definition | Signing key | +| ----------------- | --------------- | -------------------------------------------------- | -------------------------------------- | +| `ubuntu2404` | `apt` | `/etc/apt/sources.list.d/amdrocm-.sources` | `/usr/share/keyrings/amdrocm.gpg` | +| `rhel8`, `rhel10` | `dnf` / `yum` | `/etc/yum.repos.d/amdrocm-.repo` | `/etc/pki/rpm-gpg/RPM-GPG-KEY-amdrocm` | +| `sles16` | `zypper` | `/etc/zypp/repos.d/amdrocm-.repo` | `/etc/pki/rpm-gpg/RPM-GPG-KEY-amdrocm` | + +The filename and the rpm section id both identify the stream. The stem is +`amdrocm-` for every stream except `rc`, whose file is +`amdrocm-stablerc` — see [Streams](#streams). To change streams, see +[Switching streams](#switching-streams). + +The deb repo definition uses the deb822 `.sources` format (`X-Repo-Id`, +`Suites: stable`, `Components: main`, `Architectures: amd64`, `Enabled: yes`) +and references the shipped keyring via `Signed-By`. The rpm repo definition +references the shipped key via `gpgkey=file://`. For `stable`, these configure +the same repository and signing key as the [ROCm installation +instructions](https://rocm.docs.amd.com/en/latest/install/rocm.html); the files +differ in where the key is stored, the repository's display name, and +formatting. + +## Streams + +Each stream is served from its own subdomain, `.repo.amd.com`, and every +repository serves x86_64 (`amd64`) packages only. + +| Stream | Repository base | Signed | Installed repo file | +| --------- | ------------------------------------------------- | ------ | --------------------------------- | +| `stable` | `https://stable.repo.amd.com/rocm/core/packages` | yes | `amdrocm-stable.{sources,repo}` | +| `rc` | `https://rc.repo.amd.com/rocm/core/packages` | yes | `amdrocm-stablerc.{sources,repo}` | +| `nightly` | `https://nightly.repo.amd.com/rocm/core/packages` | no | `amdrocm-nightly.{sources,repo}` | + +`rc` is the release-candidate stream for the next GA release. Its package carries +the prerelease version of the ROCm packages it serves (`10.1.0~pre3` on deb, +`10.1.0~rc3` on rpm), so it sorts below the stable package at the same ROCm +version. It targets the *next* release, though, so moving from `rc` back to +`stable` is usually a downgrade — see [Switching streams](#switching-streams). + +Note that `rc`'s installed file is `amdrocm-stablerc`, not `amdrocm-rc`. The +stream names the subdomain, while `stablerc` is the spelling RFC0012 uses for +that stream's package (`amdrocm-repo-stablerc`). Matching it now means a future +split into per-tier packages never has to move a file that is already on a +user's disk. + +The streams are published per distro and differ only in whether a build +identifier appears in the path: + +| Stream | deb `URIs` | rpm `baseurl` | +| --------- | -------------------------------- | --------------------------------------- | +| `stable` | `//` | `//x86_64/` | +| `rc` | `//` | `//x86_64/` | +| `nightly` | `///` | `///x86_64/` | + +The signing key is at `/gpg/packages.gpg` — for example +`https://stable.repo.amd.com/rocm/gpg/packages.gpg`. Note that this is *not* +under the repository base: packages live under `/core/packages/` +while the key sits beside `core/`, so the two are supplied separately when +building the package. + +> **Nightly is unsigned and expires.** The nightly repository publishes no +> signatures, so the nightly `amdrocm-repo` disables signature verification +> (`gpgcheck=0` / apt `Trusted: yes`) and ships no key. +> +> More importantly, a nightly repo file names **one specific build**. Nightly +> retention prunes old builds, so a nightly `amdrocm-repo` stops resolving once +> the build it points at is removed — `apt update` or `dnf makecache` then fails +> against it — and the package must be rebuilt to follow the stream. Use +> `stable` for anything beyond short-lived testing. + +Two further streams exist on `repo.amd.com` but no `amdrocm-repo` is built for +them: `weekly` is not yet serving content, and `dev` is intended for developer +testing rather than end users. + +## Relationship to `amdgpu-install` + +`amdrocm-repo` declares a conflict with `amdgpu-install`, so the two packages +cannot be installed at the same time. `amdgpu-install` ships its own ROCm +repository definition (`/etc/apt/sources.list.d/rocm.list`, or the `[rocm]` +entry in `/etc/yum.repos.d/rocm.repo`) pointing at `repo.radeon.com`, and gives +that repository a higher priority than the default. Without the conflict, both +would install and the system would end up with two ROCm repositories +configured, the `repo.radeon.com` one taking precedence. + +Use one or the other. To switch, remove `amdgpu-install` first: + +```bash +sudo apt purge amdgpu-install # Ubuntu: purge, not remove (see the note below) +sudo dnf remove amdgpu-install # RHEL / Rocky / Oracle Linux +sudo zypper remove amdgpu-install # SLES +``` + +`amdgpu-install` also configures the AMDGPU driver repository (`amdgpu.list` or +`amdgpu.repo`). Purging it (Debian/Ubuntu) or removing it (RHEL/SLES) removes +that repository configuration too. + +> **On Debian and Ubuntu, use `purge` rather than `remove`.** `amdgpu-install` +> ships its repository file, its signing key and its priority pin +> (`/etc/apt/preferences.d/repo-radeon-pin-600`) as conffiles, which +> `apt remove` deliberately keeps. After a plain `remove` the package is gone +> but the repository stays configured, still has its key, and still outranks +> this one. The same applies if you let `apt` resolve the conflict for you: +> installing `amdrocm-repo` over `amdgpu-install` removes that package but +> leaves those files behind. `dnf` and `zypper` refuse the install outright +> instead, and erase their repository files when the package is removed, so no +> extra step is needed there. +> +> This is not cosmetic. That pin sets priority 600 against the default 500 this +> repository uses, and apt resolves by priority before version, so **ROCm keeps +> installing from `repo.radeon.com`** even when this repository offers a newer +> release. Both repositories publish the same `amdrocm*` package names, so the +> substitution is silent. +> +> On Debian and Ubuntu the package checks for exactly this state at install +> time and warns, so you do not have to remember. +> +> To check afterwards: +> +> ```bash +> apt-cache policy amdrocm +> ``` +> +> The candidate should come from the repository this package configures, not +> from `repo.radeon.com`. + +## Building the package locally + +`build_repo_package.py` builds `amdrocm-repo` for a single OS profile and +stream. Use Python 3.10 or newer with the `jinja2` Python package, plus the +native packaging tools for the target format: + +- deb (`ubuntu2404`): `debhelper`, `dpkg-dev`, `build-essential` +- rpm (`rhel8`, `rhel10`, `sles16`): `rpm-build` + +Some rpm base images ship no system `python3` at all — UBI 8, for example — so +Python may need installing first. `setup_python_cmd.sh --install-runtime` does +that per profile and reports the interpreter to use. + +For a signed stream the builder also invokes `gpg` to prepare the key, and +fetches it over the network (see below), so `gpg` and `ca-certificates` must be +present. + +`setup_repo_build_deps.sh` installs that set for a given profile, picking `apt`, +`zypper` or `dnf` to match: + +```bash +bash build_tools/packaging/linux/setup_repo_build_deps.sh --os-profile ubuntu2404 +``` + +It does not install Python — use `setup_python_cmd.sh` for that, which knows the +per-distro package names (SLES, for instance, has no unversioned `python3` or +`python3-pip` to install). + +```bash +python3 build_tools/packaging/linux/build_repo_package.py \ + --os-profile ubuntu2404 \ + --stream stable \ + --repo-base-url https://stable.repo.amd.com/rocm/core/packages \ + --gpg-key-url https://stable.repo.amd.com/rocm/gpg/packages.gpg \ + --rocm-version 10.0.0 \ + --dest-dir ./repo-package-out +``` + +`--gpg-key-url` is the full URL of the key, used verbatim. It is a separate +option because the key is not under `--repo-base-url` — packages live under +`/core/packages/` and the key beside `core/` — so one cannot be derived +from the other. Taking it whole rather than assembling it from a root also keeps +where the key lives a property of the repository rather than an assumption in +this tool. The fetch is https-only, and the fetched file must hold only the key +pinned in `build_repo_package.py`, so a wrong, tampered or added key fails the +build regardless of the URL it came from. + +For a signed stream the key is fetched over the network at build time. To build +offline, pass a key file you already trust instead. It is embedded as provided; +the pinned-key check applies only to a fetched key: + +```bash +python3 build_tools/packaging/linux/build_repo_package.py \ + --os-profile rhel10 \ + --stream stable \ + --repo-base-url https://stable.repo.amd.com/rocm/core/packages \ + --gpg-key-file ./packages.gpg \ + --rocm-version 10.0.0 \ + --dest-dir ./repo-package-out +``` + +The nightly stream is pinned to a single build; pass its identifier. No key is +needed, since the nightly repository is unsigned: + +```bash +python3 build_tools/packaging/linux/build_repo_package.py \ + --os-profile ubuntu2404 \ + --stream nightly \ + --repo-base-url https://nightly.repo.amd.com/rocm/core/packages \ + --repo-sub-folder 20260722-123456789 \ + --rocm-version 10.0.0~20260722 \ + --dest-dir ./repo-package-out +``` + +Pass `--verify-repo-url` to fail the build unless the repository the package +configures is reachable. This catches a package that would install cleanly but +fail on the user's first metadata refresh: + +```bash +python3 build_tools/packaging/linux/build_repo_package.py \ + --os-profile rhel10 \ + --stream stable \ + --repo-base-url https://stable.repo.amd.com/rocm/core/packages \ + --gpg-key-url https://stable.repo.amd.com/rocm/gpg/packages.gpg \ + --rocm-version 10.0.0 \ + --dest-dir ./repo-package-out \ + --verify-repo-url +``` + +`--verify-repo-url` has no effect for the nightly stream: its build folder is +published by the same run that builds the package, so it does not exist yet. + +### Package naming + +Every OS profile builds a *different* package, because each one embeds its own +repository URL. The profile is therefore part of the package name, so that two +of them can never be mistaken for each other. Each format does this its own way: +deb carries the profile in the version, rpm in the release field. + +`--rocm-version` is used as given, for every stream. Pass the version of the +ROCm packages in the repository the package configures: the +`rocm_deb_package_version` or `rocm_rpm_package_version` that +`build_tools/compute_rocm_package_version.py` prints for the stream's release +type. CI passes exactly that, so the package sorts the way those ROCm packages +do — below stable at the same ROCm version. The rc versions differ between deb +and rpm. + +| `--os-profile` | `--stream` | `--rocm-version` | Resulting file | +| -------------- | ---------- | ----------------- | ------------------------------------------------------- | +| `ubuntu2404` | `stable` | `10.0.0` | `amdrocm-repo_10.0.0-1~ubuntu2404_all.deb` | +| `ubuntu2404` | `rc` | `10.0.0~pre1` | `amdrocm-repo_10.0.0~pre1-1~ubuntu2404_all.deb` | +| `ubuntu2404` | `nightly` | `10.0.0~20260716` | `amdrocm-repo_10.0.0~20260716-1~ubuntu2404_all.deb` | +| `rhel8` | `stable` | `10.0.0` | `amdrocm-repo-10.0.0-1.stable.el8.noarch.rpm` | +| `rhel8` | `rc` | `10.0.0~rc1` | `amdrocm-repo-10.0.0~rc1-1.rc.el8.noarch.rpm` | +| `rhel8` | `nightly` | `10.0.0~20260716` | `amdrocm-repo-10.0.0~20260716-1.nightly.el8.noarch.rpm` | +| `rhel10` | `stable` | `10.0.0` | `amdrocm-repo-10.0.0-1.stable.el10.noarch.rpm` | +| `sles16` | `stable` | `10.0.0` | `amdrocm-repo-10.0.0-1.stable.sles16.noarch.rpm` | + +The nightly rows add `--repo-sub-folder 20260716-12345`. + +The `-1` is `--version-suffix`, this package's own build number (default `1`). +Bump it to re-ship the repository configuration at an unchanged ROCm version — +without it there would be no higher version for the package manager to accept, +and installing the corrected package would silently do nothing. + +The deb token is the `--os-profile` name rather than a Debian codename, because +codenames are not alphabetically ordered: `bullseye` sorts *above* `bookworm`, +so a Debian 11 → 12 upgrade would see the newer package as a downgrade. On RHEL +the token is rpm's conventional `.el8`/`.el10`. SUSE defines no equivalent +token — `%{?dist}` is empty there — so the profile name is used instead. + +See `build_repo_package.py --help` for the full option list. + +## Installing and using the repository + +`amdrocm-repo` is not yet published to a public download URL, so build it for +your distro first using the previous section. Then install the resulting file, +refresh the package manager's metadata, and install ROCm packages. + +The examples below assume `--dest-dir ./repo-package-out`. The built filename +carries the package version, so the glob below is what selects it. + +Ubuntu: + +```bash +sudo apt install ./repo-package-out/amdrocm-repo_*_all.deb +sudo apt-get update +sudo apt install amdrocm +``` + +RHEL / Rocky / Oracle Linux: + +```bash +sudo dnf install ./repo-package-out/amdrocm-repo-*.noarch.rpm +sudo dnf makecache +sudo dnf install amdrocm +``` + +SLES: + +```bash +sudo zypper install --allow-unsigned-rpm ./repo-package-out/amdrocm-repo-*.noarch.rpm +sudo zypper refresh +sudo zypper install amdrocm +``` + +`zypper` verifies the signature of the package file itself, and a locally built +`amdrocm-repo` is unsigned, so `--allow-unsigned-rpm` is required on SLES +whichever stream the package configures. `apt` and `dnf` install a local file +without it. + +On a signed stream, `zypper` prompts you to trust the repository signing key the +first time it refreshes; accept it to continue. The package ships the key and +points `gpgkey=` at it, but trusting it is still the user's decision, so an +unattended run has to say so explicitly: + +```bash +sudo zypper --non-interactive --gpg-auto-import-keys refresh +``` + +Without `--gpg-auto-import-keys`, `--non-interactive` answers the prompt with +"reject" and skips the repository, so a scripted install fails with no packages +found. `dnf` takes the same decision with `-y`, and `apt` needs no equivalent +because `Signed-By:` names the keyring directly. + +### Switching streams + +On Debian and Ubuntu, switching in place is not supported yet: purge the +installed package first, then install the other stream's package. + +```bash +sudo apt purge amdrocm-repo +sudo apt install ./repo-package-out/amdrocm-repo_*_all.deb +``` + +Installing over the current package keeps the old stream's `.sources` file, +because `dpkg` does not delete a configuration file that the version being +installed no longer ships. The old repository stays active: after moving from `rc` to +`stable`, `apt` keeps resolving `amdrocm` to the higher rc version, and after +moving from a signed stream to `nightly`, `apt-get update` can no longer +refresh the old repository, whose signing key was removed. + +On RHEL, `dnf install` switches directly, reporting a move to a lower-versioned +stream as a downgrade. On SLES, pass `--oldpackage`: without it, `zypper` skips a +lower-versioned package with "Nothing to do" and still exits 0. + +```bash +sudo dnf install ./repo-package-out/amdrocm-repo-*.noarch.rpm +sudo zypper install --oldpackage --allow-unsigned-rpm ./repo-package-out/amdrocm-repo-*.noarch.rpm +``` + +Refresh the package manager metadata afterwards, exactly as after a first +install. Switching changes where packages are installed from; ROCm packages that +are already installed keep their version. Moving to a lower-versioned stream, such +as from `rc` back to `stable`, therefore means removing or downgrading those +packages as well. + +### Removing the package + +On Debian and Ubuntu, use `sudo apt purge amdrocm-repo`. `apt remove` keeps the +`.sources` file, so the repository stays configured. On a signed stream it also +deletes the key, so every later `apt-get update` warns `NO_PUBKEY`. On RHEL and +SLES, `dnf remove amdrocm-repo` or `zypper remove amdrocm-repo` deletes both the +repository file and the key. + +### Verifying the repository + +Confirm the repository is configured by resolving the `amdrocm` package: + +```bash +apt-cache policy amdrocm # Ubuntu +dnf info amdrocm # RHEL / Rocky / Oracle Linux +zypper info amdrocm # SLES +``` + +A candidate version means the repository is reachable and its metadata is +current. No candidate means the repository is not configured, or its metadata +has not been refreshed. + +To browse everything the repository publishes, list by prefix instead — expect +several hundred entries, since each component ships separately: + +```bash +apt list 'amdrocm*' # Ubuntu +dnf list 'amdrocm*' # RHEL / Rocky / Oracle Linux +zypper search amdrocm # SLES +``` + +## Troubleshooting + +- **`dnf` refuses with `conflicts with amdgpu-install ... conflicting requests`.** + Remove `amdgpu-install` first — see + [Relationship to `amdgpu-install`](#relationship-to-amdgpu-install). Do not + reach for `--allowerasing`; removing it deliberately is the point. +- **`apt` says `The following packages will be REMOVED: amdgpu-install`.** + That is the conflict being resolved, and it is not the whole story: the + removal is not a purge, so that package's repository file and priority pin + remain. See the note in + [Relationship to `amdgpu-install`](#relationship-to-amdgpu-install). +- **`dpkg -i` refuses with `conflicting packages - not installing amdrocm-repo`.** + `dpkg` will not resolve the conflict for you; purge `amdgpu-install` first. +- **`zypper` reports `Signature verification failed [6-File is unsigned]`.** + The package file is unsigned; pass `--allow-unsigned-rpm`. +- **`apt` reports `Packages were downgraded and -y was used without --allow-downgrades`.** You are installing another stream's package over the + current one. Purge `amdrocm-repo` first; see + [Switching streams](#switching-streams). +- **`amdrocm*` packages are not listed.** Refresh the package manager metadata + (`sudo apt-get update`, `sudo dnf makecache`, or `sudo zypper refresh`) after + installing `amdrocm-repo`. +- **`amdrocm` resolves to an unexpected version.** Another ROCm repository is + configured and is winning. Check with `apt-cache policy amdrocm`, `dnf info amdrocm`, or `zypper info amdrocm`, which name the repository each candidate + comes from. If it is `repo.radeon.com` and `amdgpu-install` is still present + in a removed-but-not-purged state, `sudo apt purge amdgpu-install` clears it + along with its priority pin. If instead the repository was configured by hand + following older setup instructions, no package owns those files and they must + be removed by hand — look for entries referencing `repo.radeon.com` under + `/etc/apt/sources.list.d/` or `/etc/yum.repos.d/`. Remove only the ROCm + entries: the same host also serves the AMDGPU driver repository, which is + unrelated to this package and is usually still wanted. +- **`zypper` reports an untrusted signing key.** Accept the key when prompted on + the first refresh of a signed stream. Non-interactively, pass + `--gpg-auto-import-keys`; see + [Installing and using the repository](#installing-and-using-the-repository).