From b1cc2901b5b861f8ec66b35d982f6fff6106839f Mon Sep 17 00:00:00 2001 From: a0x1ab <59631311+a0x1ab@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:39:51 +0000 Subject: [PATCH] [Packaging] Fix #34131: `az --version`: Enable Fedora 44 RPM build and validation --- scripts/release/rpm/README.md | 174 ++++++++++- scripts/release/rpm/fedora.dockerfile | 8 +- scripts/release/rpm/test_rpm_in_docker.sh | 2 +- scripts/release/rpm/test_rpm_package.py | 86 ++++-- scripts/release/rpm/tests/test_rpm_package.py | 161 ++++++++++ scripts/release/rpm/tests/test_verify_rpm.py | 283 ++++++++++++++++++ scripts/release/rpm/verify_rpm_in_docker.sh | 85 ++++++ 7 files changed, 760 insertions(+), 39 deletions(-) create mode 100644 scripts/release/rpm/tests/test_rpm_package.py create mode 100644 scripts/release/rpm/tests/test_verify_rpm.py diff --git a/scripts/release/rpm/README.md b/scripts/release/rpm/README.md index b5d5dee0cf1..9b2e22f28d8 100644 --- a/scripts/release/rpm/README.md +++ b/scripts/release/rpm/README.md @@ -8,12 +8,26 @@ _Enterprise Linux:_ ```bash docker build --target build-env -f ./scripts/release/rpm/centos7.dockerfile -t azure/azure-cli:centos7-builder . ``` -_Fedora:_ +_Fedora 44 (local build, not production publication):_ ```bash -docker build --target build-env -f ./scripts/release/rpm/fedora.dockerfile -t azure/azure-cli:fedora29-builder . +CLI_VERSION=$(sed -n 's/^__version__ = "\(.*\)"/\1/p' src/azure-cli/azure/cli/__main__.py) +docker build --target build-env \ + --build-arg image=registry.fedoraproject.org/fedora:44 \ + --build-arg cli_version="$CLI_VERSION" --build-arg python_package=python3 \ + -f ./scripts/release/rpm/fedora.dockerfile -t azure/azure-cli:fedora44-builder . ``` +Fedora 44's native `python3` package provides Python 3.14 and `/usr/bin/python3`. +Use `python3-devel` for building and `python3-pip` (`pip3`) for the package tests, +not the UBI matrix's `python3.12`/`pip3.12` selectors. The Python libraries require +`python-pip-wheel`, which supplies the virtual environment's pip bootstrap. +These package names and executable paths are present in the +[Fedora 44 RPM metadata](https://dl.fedoraproject.org/pub/fedora/linux/releases/44/Everything/x86_64/os/Packages/p/). +The [Fedora image manifest](https://registry.fedoraproject.org/v2/fedora/manifests/44) +includes both `amd64` and `arm64`; a successful build and package run on each +architecture is still required. + _Azure Linux:_ ```bash @@ -25,7 +39,7 @@ docker build --target build-env --build-arg image=mcr.microsoft.com/azurelinux/b -f ./scripts/release/rpm/azurelinux.dockerfile -t azure/azure-cli:azurelinux3-builder . ``` -After several minutes, this will have created a Docker image named `azure/azure-cli:centos7-builder` containing an +After several minutes, this will have created the selected Docker image containing an unsigned `.rpm` built from the current contents of your azure-cli directory. To extract the build product from the image you can run the following command: @@ -34,9 +48,13 @@ _Enterprise Linux:_ docker run azure/azure-cli:centos7-builder cat /root/rpmbuild/RPMS/x86_64/azure-cli-dev-1.el7.x86_64.rpm > ./bin/azure-cli-dev-1.el7.x86_64.rpm ``` -_Fedora:_ +_Fedora 44:_ ```bash -docker run azure/azure-cli:fedora29-builder cat /root/rpmbuild/RPMS/x86_64/azure-cli-dev-1.fc29.x86_64.rpm > ./bin/azure-cli-dev-1.fc29.x86_64.rpm +id=$(docker create azure/azure-cli:fedora44-builder) +docker cp "$id:/out/." ./bin/ +docker rm "$id" +# Native amd64 output: azure-cli-${CLI_VERSION}-1.fc44.x86_64.rpm +# Native arm64 output: azure-cli-${CLI_VERSION}-1.fc44.aarch64.rpm ``` _Azure Linux:_ @@ -48,19 +66,26 @@ docker run azure/azure-cli:azurelinux-builder cat /root/rpmbuild/RPMS/x86_64/azu docker run azure/azure-cli:azurelinux3-builder cat /usr/src/azl/RPMS/x86_64/azure-cli-dev-1.azl3.x86_64.rpm > ./bin/azure-cli-dev-1.azl3.x86_64.rpm ``` -This launches a container running from the image built and tagged by the previous command, prints the contents of the -built package to standard out, and pipes it to a file on your host machine. +The `docker run ... cat` commands copy a specific package through standard output. +The Fedora example copies `/out/` without assuming the native RPM architecture. ### Additional Build Flags `--build-arg cli_version={your version string}` This will allow you to name your build. If not specified, the value "dev" is assumed. +Use the CLI source version as above for release builds. The package tests require +the reported CLI version to match the RPM version, except for the default `dev` +label, which does not change the CLI's embedded source version. Development builds +still run the version command and all other package checks. -`--build-arg tag={centos/fedora version}` +`--build-arg image={container image}` -RPMs must be built using a Red Hat distro or derivative. By default, this build uses CentOS7, but one could easily tweak -it to include slightly different packages for distribution. +The Fedora Dockerfile defaults to `registry.fedoraproject.org/fedora:44`. Its +`python_package` argument defaults to `python3`, and `python_cmd` defaults to the +selected package name. Both can be overridden when building directly with Docker. +The existing `pipeline.sh` selects the image and Python package through `IMAGE` +and `PYTHON_PACKAGE`; its Fedora defaults therefore use the same Python executable. ### Verification @@ -73,6 +98,13 @@ On a machine with Docker, execute the following command from the root directory docker build -f ./scripts/release/rpm/centos7.dockerfile -t azure/azure-cli:centos7 . ``` +For Fedora 44, run both installation smoke checks (`az --version` and `az self-test`): + +```bash +docker build --build-arg cli_version="$CLI_VERSION" \ + -f ./scripts/release/rpm/fedora.dockerfile -t azure/azure-cli:fedora44 . +``` + If you had previously followed this instructions above for building an RPM package, this should finish very quickly. Otherwise, it'll take a few minutes to create an image with a copy of the azure-cli installed. > Note: The image that is created by this command does not contain the source code of the azure-cli. @@ -101,6 +133,128 @@ To remove: sudo rpm -e azure-cli ``` +### Fedora 44 local package tests + +Use a disposable, normal Git checkout on each native Linux architecture. The +existing test script builds test wheels with `scripts/ci/build.sh`, which requires +Git metadata and modifies the mounted checkout. It cannot run from a source-only +snapshot. After extracting the unsigned RPM to `bin/` as above: + +```bash +RPM_TEST_RESULTS=$(mktemp -d) +docker run --rm \ + -v "$(pwd):/azure-cli" -v "$(pwd)/bin:/mnt/rpm:ro" \ + -v "$RPM_TEST_RESULTS:/azure_cli_test_result" \ + -e RPM_NAME="azure-cli-${CLI_VERSION}-1.fc44.*.rpm" \ + -e PYTHON_PACKAGE=python3 -e PYTHON_CMD=python3 -e PIP_CMD=pip3 \ + registry.fedoraproject.org/fedora:44 \ + bash /azure-cli/scripts/release/rpm/test_rpm_in_docker.sh +``` + +This runs the existing self-test, version smoke check and package suites. The +package checks also require the installed RPM to match the requested version and +distro suffix, have the native architecture, report the same CLI version (except +for the `dev` label), and declare an installed dependency on the selected Python +package. The local installer's `--nogpgcheck` is only for unsigned build artifacts; +it is not suitable for production-feed acceptance. + +Credential-free regression checks for the packaging scripts use the existing +Python unittest tooling: + +```bash +python -m unittest discover -s scripts/release/rpm/tests +``` + +These checks do not build an RPM or replace Fedora/UBI/Azure Linux container CI. + +### Fedora 44 CI and release-owner gates + +The main `azure-pipelines.yml` currently has no Fedora 44 entry in either +`BuildRpmPackages` or `TestRpmPackage`. The Dockerfile change alone does not add +those jobs. After release-owner approval, the paired entries under the existing +architecture loops must use the same image, artifact and Python selection: + +```yaml +# BuildRpmPackages matrix +Fedora 44 ${{ arch.name }}: + dockerfile: fedora + image: registry.fedoraproject.org/fedora:44 + artifact: rpm-fedora44-${{ arch.value }} + python_package: python3 + pool: ${{ arch.pool }} + +# TestRpmPackage matrix +Fedora 44 ${{ arch.name }}: + artifact: rpm-fedora44-${{ arch.value }} + distro: fc44 + image: registry.fedoraproject.org/fedora:44 + python_package: python3 + python_cmd: python3 + pip_cmd: pip3 + pool: ${{ arch.pool }} +``` + +Keep the existing build/test dependencies, trigger conditions, UBI entries and +separate Azure Linux jobs unchanged. `rpm-fedora44-amd64` must contain the +`1.fc44.x86_64.rpm` artifact, and `rpm-fedora44-arm64` the `1.fc44.aarch64.rpm` +artifact; the install jobs must download the corresponding artifact. + +`PublishPipelineArtifact` only uploads an Azure Pipelines artifact. No approved +Fedora 44 signing/production publication mapping is defined in this repository. +The `/scripts/` CODEOWNERS teams can route a release-owner review, but code +ownership is not approval to publish to a feed. The release owner must confirm +the supported destination and signing key, onboard the artifact through the +existing approved signing/publication process, and complete post-publication +acceptance. Do not infer a destination from an issue URL or substitute a +RHEL/CentOS feed. + +Local builds, CI artifacts and even successful local installations do not +establish Fedora 44 production availability or Microsoft support. + +### Fedora 44 repository acceptance (after approved publication) + +The release owner must first configure the approved repository and trusted signing +keys in a clean Fedora 44 container on each native architecture. Do not use a +container that already has the Azure CLI RPM installed or an `az` executable on +`PATH`. From the mounted repository root, run as root: + +```bash +# Use the approved repository ID and the exact version the owner published. +RPM_REPOSITORY_ID="${APPROVED_REPO_ID:?Set the approved Fedora repository ID}" \ +CLI_VERSION="${EXPECTED_VERSION:?Set the published X.Y.Z CLI version}" \ +RPM_RELEASE=1.fc44 \ +bash scripts/release/rpm/verify_rpm_in_docker.sh +``` + +`RPM_REPOSITORY_ID` selects an existing repository, not a URL. `CLI_VERSION` must be +an exact `X.Y.Z` version, not `dev` or `latest`; set `RPM_RELEASE` to the published +numeric release with the `.fc44` suffix. The verifier refreshes available metadata +restricted to the selected repository, then installs that exact version, release +and native architecture from it. Other configured repositories may satisfy +dependencies, but cannot substitute their Azure CLI package. + +Package signature checking is required, and any configured repository-metadata +signature policy is preserved. This mode does not configure a repository or choose +signing keys. Never copy the local installer's `--nogpgcheck` option into this +acceptance procedure. + +| Scenario | Required result | +| --- | --- | +| Requested version, release and native architecture are available from the approved repository | Install succeeds; installed RPM metadata, DNF origin, `/usr/bin/az` ownership and reported CLI version match; `az --version` and `az self-test` succeed. | +| Selected repository has no package, only an older version, or the wrong release/architecture | Fail, even if another enabled repository has a matching package. | +| Package is available only from Fedora's downstream repository or another unrelated feed | Fail without falling back to that feed. | +| Azure CLI RPM or an `az` executable is already present | Fail; retry in a fresh container. | +| Package signature checking or an enabled metadata-signature check fails | Fail; do not bypass the signature check. | +| Installed package origin/version/ownership differs, or either smoke check fails | Fail. | + +The script neither starts a release nor waits for a package to appear. Run it only +after the release owner confirms approved publication. A successful local build +or unsigned artifact installation cannot satisfy this acceptance gate. + +Leaving `RPM_REPOSITORY_ID` unset preserves the existing generic-yum verification +path; that path is not Fedora 44 repository acceptance. An explicitly empty +selector is rejected rather than silently selecting the legacy path. + Links ----- diff --git a/scripts/release/rpm/fedora.dockerfile b/scripts/release/rpm/fedora.dockerfile index 66f0295af42..62164395013 100644 --- a/scripts/release/rpm/fedora.dockerfile +++ b/scripts/release/rpm/fedora.dockerfile @@ -1,8 +1,9 @@ -ARG image=fedora:35 +ARG image=registry.fedoraproject.org/fedora:44 FROM ${image} AS build-env ARG cli_version=dev ARG python_package=python3 +ARG python_cmd=${python_package} RUN dnf update -y RUN dnf install -y wget rpm-build gcc libffi-devel ${python_package}-devel openssl-devel make bash coreutils diffutils patch dos2unix perl @@ -12,7 +13,7 @@ WORKDIR /azure-cli COPY . . RUN dos2unix ./scripts/release/rpm/azure-cli.spec && \ - REPO_PATH=$(pwd) CLI_VERSION=$cli_version PYTHON_PACKAGE=$python_package PYTHON_CMD=python3 \ + REPO_PATH=$(pwd) CLI_VERSION=$cli_version PYTHON_PACKAGE=$python_package PYTHON_CMD=$python_cmd \ rpmbuild -v -bb --clean scripts/release/rpm/azure-cli.spec && \ cp /root/rpmbuild/RPMS/*/azure-cli-${cli_version}-1.*.rpm /azure-cli-dev.rpm && \ mkdir /out && cp /root/rpmbuild/RPMS/*/azure-cli-${cli_version}-1.*.rpm /out/ @@ -21,4 +22,5 @@ FROM ${image} AS execution-env COPY --from=build-env /azure-cli-dev.rpm ./ RUN dnf install -y ./azure-cli-dev.rpm && \ - az --version + az --version && \ + az self-test diff --git a/scripts/release/rpm/test_rpm_in_docker.sh b/scripts/release/rpm/test_rpm_in_docker.sh index de1be306174..09cc725627f 100644 --- a/scripts/release/rpm/test_rpm_in_docker.sh +++ b/scripts/release/rpm/test_rpm_in_docker.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# This script should be run in a ubi8, ubi9 docker. +# This script should be run in a UBI or Fedora container. set -exv export USERNAME=azureuser diff --git a/scripts/release/rpm/test_rpm_package.py b/scripts/release/rpm/test_rpm_package.py index 8b055664f4d..26a98f555a3 100644 --- a/scripts/release/rpm/test_rpm_package.py +++ b/scripts/release/rpm/test_rpm_package.py @@ -3,31 +3,67 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +import fnmatch import os -import sys import subprocess +import sys + + +def verify_rpm_installation(rpm_name=None, python_package=None): + metadata = subprocess.check_output( + ['rpm', '-q', '--queryformat', '%{NAME} %{VERSION} %{RELEASE} %{ARCH}', 'azure-cli'], + text=True).split() + if len(metadata) != 4 or metadata[0] != 'azure-cli': + raise RuntimeError(f'Expected one installed azure-cli RPM, got: {metadata}') + name, version, release, arch = metadata + filename = f'{name}-{version}-{release}.{arch}.rpm' + if rpm_name and not fnmatch.fnmatchcase(filename, rpm_name): + raise RuntimeError(f'Installed RPM {filename} does not match the requested artifact {rpm_name}') + + native_arch = subprocess.check_output(['rpm', '--eval', '%{_arch}'], text=True).strip() + if arch != native_arch: + raise RuntimeError(f'Installed RPM architecture {arch} does not match {native_arch}') + + cli_version = subprocess.check_output( + ['az', 'version', '--query', '"azure-cli"', '--output', 'tsv'], text=True).strip() + # Development builds label the RPM "dev" without changing the embedded CLI version. + if version != 'dev' and cli_version != version: + raise RuntimeError(f'Installed CLI version {cli_version} does not match RPM version {version}') + + if python_package: + requires = subprocess.check_output(['rpm', '-q', '--requires', 'azure-cli'], text=True) + if not any(line.split()[0] == python_package for line in requires.splitlines() if line.strip()): + raise RuntimeError(f'Installed RPM does not require the selected Python package {python_package}') + subprocess.check_call(['rpm', '-q', python_package]) + + +def main(): + verify_rpm_installation(os.environ.get('RPM_NAME'), os.environ.get('PYTHON_PACKAGE')) + python_version = os.listdir('/usr/lib64/az/lib/')[0] + root_dir = f'/usr/lib64/az/lib/{python_version}/site-packages/azure/cli/command_modules' + mod_list = [mod for mod in sorted(os.listdir(root_dir)) if os.path.isdir(os.path.join(root_dir, mod)) and mod != '__pycache__'] + + pytest_base_cmd = f'PYTHONPATH=/usr/lib64/az/lib/{python_version}/site-packages python -m pytest -v --forked -p no:warnings --log-level=WARN' + pytest_parallel_cmd = '{} -n logical'.format(pytest_base_cmd) + + # cloud: https://github.com/Azure/azure-cli/pull/14994 + # appservice: https://github.com/Azure/azure-cli/pull/19810 + # iot, resource, azure-cli-core: https://github.com/Azure/azure-cli/pull/26176 + serial_test_modules = ['botservice', 'network', 'cloud', 'appservice', 'iot', 'resource'] + + for mod_name in mod_list: + cmd = '{} --junit-xml /azure_cli_test_result/{}.xml --pyargs azure.cli.command_modules.{}'.format( + pytest_base_cmd if mod_name in serial_test_modules else pytest_parallel_cmd, mod_name, mod_name) + print('Running:', cmd, flush=True) + exit_code = subprocess.call(cmd, shell=True) + if exit_code == 5: + print('No tests found for {}'.format(mod_name)) + elif exit_code != 0: + sys.exit(exit_code) + + exit_code = subprocess.call(['{} --junit-xml /azure_cli_test_result/azure-cli-core.xml --pyargs azure.cli.core'.format(pytest_base_cmd)], shell=True) + sys.exit(exit_code) + -python_version = os.listdir('/usr/lib64/az/lib/')[0] -root_dir = f'/usr/lib64/az/lib/{python_version}/site-packages/azure/cli/command_modules' -mod_list = [mod for mod in sorted(os.listdir(root_dir)) if os.path.isdir(os.path.join(root_dir, mod)) and mod != '__pycache__'] - -pytest_base_cmd = f'PYTHONPATH=/usr/lib64/az/lib/{python_version}/site-packages python -m pytest -v --forked -p no:warnings --log-level=WARN' -pytest_parallel_cmd = '{} -n logical'.format(pytest_base_cmd) - -# cloud: https://github.com/Azure/azure-cli/pull/14994 -# appservice: https://github.com/Azure/azure-cli/pull/19810 -# iot, resource, azure-cli-core: https://github.com/Azure/azure-cli/pull/26176 -serial_test_modules = ['botservice', 'network', 'cloud', 'appservice', 'iot', 'resource'] - -for mod_name in mod_list: - cmd = '{} --junit-xml /azure_cli_test_result/{}.xml --pyargs azure.cli.command_modules.{}'.format( - pytest_base_cmd if mod_name in serial_test_modules else pytest_parallel_cmd, mod_name, mod_name) - print('Running:', cmd, flush=True) - exit_code = subprocess.call(cmd, shell=True) - if exit_code == 5: - print('No tests found for {}'.format(mod_name)) - elif exit_code != 0: - sys.exit(exit_code) - -exit_code = subprocess.call(['{} --junit-xml /azure_cli_test_result/azure-cli-core.xml --pyargs azure.cli.core'.format(pytest_base_cmd)], shell=True) -sys.exit(exit_code) +if __name__ == '__main__': + main() diff --git a/scripts/release/rpm/tests/test_rpm_package.py b/scripts/release/rpm/tests/test_rpm_package.py new file mode 100644 index 00000000000..81a979c6e05 --- /dev/null +++ b/scripts/release/rpm/tests/test_rpm_package.py @@ -0,0 +1,161 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import importlib.util +from pathlib import Path +import subprocess +import unittest +from unittest.mock import patch + + +SPEC = importlib.util.spec_from_file_location( + 'rpm_package', Path(__file__).resolve().parents[1] / 'test_rpm_package.py') +rpm_package = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(rpm_package) + + +class FedoraBuildConfigurationTests(unittest.TestCase): + def test_native_python_defaults_and_override(self): + dockerfile = (Path(__file__).resolve().parents[1] / 'fedora.dockerfile').read_text(encoding='utf-8') + self.assertIn('ARG image=registry.fedoraproject.org/fedora:44\n', dockerfile) + self.assertIn('ARG python_package=python3\n', dockerfile) + self.assertIn('ARG python_cmd=${python_package}\n', dockerfile) + self.assertIn('PYTHON_PACKAGE=$python_package PYTHON_CMD=$python_cmd', dockerfile) + + def test_both_installation_smoke_checks(self): + dockerfile = (Path(__file__).resolve().parents[1] / 'fedora.dockerfile').read_text(encoding='utf-8') + execution_stage = dockerfile.split('AS execution-env', 1)[1] + self.assertIn('dnf install -y ./azure-cli-dev.rpm', execution_stage) + self.assertIn('az --version &&', execution_stage) + self.assertIn('az self-test', execution_stage) + + +class VerifyRpmInstallationTests(unittest.TestCase): + def setUp(self): + self.metadata = 'azure-cli 2.90.0 1.fc44 x86_64' + self.arch = 'x86_64' + self.version = '2.90.0' + self.requires = '/usr/bin/bash\npython3\n' + output_patch = patch.object(rpm_package.subprocess, 'check_output', side_effect=self.command_output) + call_patch = patch.object(rpm_package.subprocess, 'check_call') + self.output = output_patch.start() + self.call = call_patch.start() + self.addCleanup(output_patch.stop) + self.addCleanup(call_patch.stop) + + def command_output(self, command, *, text): + self.assertTrue(text) + if command[:3] == ['rpm', '-q', '--queryformat']: + return self.metadata + if command == ['rpm', '--eval', '%{_arch}']: + return self.arch + if command == ['az', 'version', '--query', '"azure-cli"', '--output', 'tsv']: + return self.version + if command == ['rpm', '-q', '--requires', 'azure-cli']: + return self.requires + self.fail(f'Unexpected command: {command}') + + def test_native_artifact_and_python_dependency(self): + for distro, python_package in [('fc44', 'python3'), ('el8', 'python3.12'), + ('el9', 'python3.12'), ('el10', 'python3.12')]: + for arch in ['x86_64', 'aarch64']: + with self.subTest(distro=distro, arch=arch): + self.metadata = f'azure-cli 2.90.0 1.{distro} {arch}' + self.arch = arch + self.requires = f'/usr/bin/bash\n{python_package}\n' + rpm_package.verify_rpm_installation(f'azure-cli-2.90.0-1.{distro}.*.rpm', python_package) + self.call.assert_called_with(['rpm', '-q', python_package]) + + def test_azure_linux_version_only_artifact_pattern(self): + for distro in ['azl3', 'azl4']: + for arch in ['x86_64', 'aarch64']: + with self.subTest(distro=distro, arch=arch): + self.metadata = f'azure-cli 2.90.0 1.{distro} {arch}' + self.arch = arch + rpm_package.verify_rpm_installation('azure-cli-2.90.0*.rpm') + self.call.assert_not_called() + + def test_existing_caller_without_optional_environment(self): + for version in ['2.90.0', 'dev']: + with self.subTest(version=version): + self.metadata = f'azure-cli {version} 1.fc44 x86_64' + rpm_package.verify_rpm_installation() + self.call.assert_not_called() + + def test_default_development_rpm_label(self): + for distro, python_package in [('fc44', 'python3'), ('el8', 'python3.12'), + ('el9', 'python3.12'), ('el10', 'python3.12'), + ('azl3', None), ('azl4', None)]: + for arch in ['x86_64', 'aarch64']: + with self.subTest(distro=distro, arch=arch): + self.metadata = f'azure-cli dev 1.{distro} {arch}' + self.arch = arch + self.requires = f'/usr/bin/bash\n{python_package or "python3"}\n' + self.call.reset_mock() + pattern = f'azure-cli-dev-1.{distro}.*.rpm' if python_package else 'azure-cli-dev*.rpm' + rpm_package.verify_rpm_installation(pattern, python_package) + if python_package: + self.call.assert_called_once_with(['rpm', '-q', python_package]) + else: + self.call.assert_not_called() + + def test_wrong_distribution_or_requested_version(self): + for pattern in ['azure-cli-2.90.0-1.el9.*.rpm', 'azure-cli-2.89.0-1.fc44.*.rpm']: + with self.subTest(pattern=pattern): + with self.assertRaisesRegex(RuntimeError, 'does not match the requested artifact'): + rpm_package.verify_rpm_installation(pattern, 'python3') + self.call.assert_not_called() + + def test_wrong_architecture(self): + self.metadata = 'azure-cli 2.90.0 1.fc44 aarch64' + with self.assertRaisesRegex(RuntimeError, 'architecture aarch64 does not match x86_64'): + rpm_package.verify_rpm_installation('azure-cli-2.90.0-1.fc44.*.rpm', 'python3') + + def test_wrong_cli_version(self): + self.version = '2.89.0' + with self.assertRaisesRegex(RuntimeError, 'CLI version 2.89.0 does not match RPM version 2.90.0'): + rpm_package.verify_rpm_installation() + + def test_wrong_python_dependency(self): + self.requires = 'python3.12\n' + for version in ['2.90.0', 'dev']: + with self.subTest(version=version): + self.metadata = f'azure-cli {version} 1.fc44 x86_64' + with self.assertRaisesRegex(RuntimeError, 'does not require the selected Python package python3'): + rpm_package.verify_rpm_installation(python_package='python3') + self.call.assert_not_called() + + def test_failed_cli_version_query_is_not_ignored(self): + for version in ['2.90.0', 'dev']: + with self.subTest(version=version): + self.output.side_effect = [ + f'azure-cli {version} 1.fc44 x86_64', self.arch, + subprocess.CalledProcessError(1, ['az', 'version']) + ] + with self.assertRaises(subprocess.CalledProcessError): + rpm_package.verify_rpm_installation() + self.call.assert_not_called() + + def test_uninstalled_python_dependency_fails(self): + self.call.side_effect = subprocess.CalledProcessError(1, ['rpm', '-q', 'python3']) + with self.assertRaises(subprocess.CalledProcessError): + rpm_package.verify_rpm_installation(python_package='python3') + + def test_invalid_or_multiple_installed_packages(self): + for metadata in ['', 'other-cli 2.90.0 1.fc44 x86_64', + 'azure-cli 2.90.0 1.fc44 x86_64 azure-cli 2.89.0 1.fc44 x86_64']: + with self.subTest(metadata=metadata): + self.metadata = metadata + with self.assertRaisesRegex(RuntimeError, 'Expected one installed azure-cli RPM'): + rpm_package.verify_rpm_installation() + + def test_failed_package_query_is_not_ignored(self): + self.output.side_effect = subprocess.CalledProcessError(1, ['rpm', '-q', 'azure-cli']) + with self.assertRaises(subprocess.CalledProcessError): + rpm_package.verify_rpm_installation() + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/release/rpm/tests/test_verify_rpm.py b/scripts/release/rpm/tests/test_verify_rpm.py new file mode 100644 index 00000000000..83ec1ac9681 --- /dev/null +++ b/scripts/release/rpm/tests/test_verify_rpm.py @@ -0,0 +1,283 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Shell-contract tests, not Fedora/RPM integration or publication verification.""" + +import os +from pathlib import Path +import shutil +import subprocess +import unittest + + +SCRIPT = Path(__file__).resolve().parents[1] / "verify_rpm_in_docker.sh" +VERSION = "2.86.0" +RELEASE = "1.fc44" +REPOSITORY = "maintainer-acceptance" +RPM_ROW = f"azure-cli|{VERSION}|{RELEASE}|x86_64" +REPO_ROW = f"{RPM_ROW}|{REPOSITORY}" + +# Source the real script in a fresh Bash process. Function stubs, including the +# absolute az path, avoid writing executables, logs, repos or keys on the host. +HARNESS = r""" +trace() { + printf '\036' + printf '%s\037' "$@" + printf '\036' +} >&2 +reply() { + if [[ "$STUB_FAIL" == "$1" ]]; then + printf 'stub failure: %s\n' "$1" >&2 + return 41 + fi + printf '%s\n' "$2" +} +installed=0 +rpm() { + trace rpm "$@" + case "$1" in + -qa) reply preflight "$STUB_PREINSTALLED" ;; + --eval) reply arch "$STUB_ARCH" ;; + -q) + [[ "$installed" == 1 ]] || return 97 + reply rpm-query "$STUB_RPM" + ;; + -qf) reply owner "$STUB_OWNER" ;; + --import) reply import "" ;; + *) return 97 ;; + esac +} +dnf5() { + trace dnf5 "$@" + case " $* " in + *" --available "*) reply available "$STUB_AVAILABLE" ;; + *" --installed "*) reply installed-origin "$STUB_ORIGIN" ;; + *" install "*) + if [[ "$STUB_FAIL" == signature ]]; then + echo "signature verification failed" >&2 + return 42 + fi + reply install "" + installed=1 + if [[ "$STUB_SHADOW_AFTER" == 1 ]]; then + az() { trace az "$@"; return 97; } + fi + ;; + *) return 97 ;; + esac +} +function /usr/bin/az() { + trace /usr/bin/az "$@" + case "$1" in + version) reply cli-version "$STUB_CLI_VERSION" ;; + --version) reply cli-banner "" ;; + self-test) reply self-test "" ;; + *) return 97 ;; + esac +} +if [[ "$STUB_PATH_CLI" == 1 || ${RPM_REPOSITORY_ID+x} != x ]]; then + az() { + trace az "$@" + printf ' "azure-cli": "%s",\n' "$STUB_CLI_VERSION" + } +fi +yum() { trace yum "$@"; reply legacy-install ""; } +sh() { trace sh "$@"; } +sleep() { trace sleep "$@"; } +sed() { "$STUB_SED" "$@"; } +source "$1" +""" + + +class VerifyRpmTests(unittest.TestCase): + def run_script(self, success=True, **overrides): + bash = shutil.which("bash") + sed = shutil.which("sed") + self.assertIsNotNone(bash, "These shell tests require Bash.") + self.assertIsNotNone(sed, "The legacy verifier requires sed.") + env = { + # No real package command or az can be found through PATH. + "PATH": os.devnull, + "LC_ALL": "C", + "RPM_REPOSITORY_ID": REPOSITORY, + "CLI_VERSION": VERSION, + "RPM_RELEASE": RELEASE, + "STUB_ARCH": "x86_64", + "STUB_PREINSTALLED": "", + "STUB_AVAILABLE": REPO_ROW, + "STUB_RPM": RPM_ROW, + "STUB_ORIGIN": REPO_ROW, + "STUB_OWNER": RPM_ROW, + "STUB_CLI_VERSION": VERSION, + "STUB_FAIL": "", + "STUB_PATH_CLI": "0", + "STUB_SHADOW_AFTER": "0", + "STUB_SED": sed, + } + for key, value in overrides.items(): + if value is None: + env.pop(key, None) + else: + env[key] = value + result = subprocess.run( + [bash, "--noprofile", "--norc", "-c", HARNESS, "verify-rpm-test", str(SCRIPT)], + cwd=os.environ.get("TMPDIR", SCRIPT.parent), + env=env, capture_output=True, text=True, timeout=10, check=False, + ) + commands = [ + tuple(record.split("\x1f")[:-1]) + for record in result.stderr.split("\x1e")[1::2] + ] + diagnostic = (result.stdout + result.stderr).replace("\x1e", "\n").replace("\x1f", " ") + self.assertEqual(result.returncode == 0, success, diagnostic) + return result, commands + + def test_selected_repository_and_native_architecture(self): + for arch in ("x86_64", "aarch64"): + with self.subTest(arch=arch): + row = f"azure-cli|{VERSION}|{RELEASE}|{arch}" + result, commands = self.run_script( + STUB_ARCH=arch, STUB_AVAILABLE=f"{row}|{REPOSITORY}", + STUB_RPM=row, STUB_ORIGIN=f"{row}|{REPOSITORY}", STUB_OWNER=row, + ) + package = f"azure-cli-{VERSION}-{RELEASE}.{arch}" + self.assertIn(f"Verified {package}", result.stdout) + self.assertEqual(commands[0], ("rpm", "-qa", "azure-cli", "--queryformat", "%{NAME}\\n")) + self.assertEqual(commands[1], ("rpm", "--eval", "%{_arch}")) + queries = [cmd for cmd in commands if cmd[0] == "dnf5"] + self.assertEqual(len(queries), 3) + available, install, installed = queries + self.assertIn("--refresh", available) + self.assertIn(f"--repo={REPOSITORY}", available) + self.assertIn("--available", available) + self.assertEqual(available[-1], package) + self.assertEqual(available[available.index("--queryformat") + 1], + "%{name}|%{version}|%{release}|%{arch}|%{repoid}\\n") + self.assertIn(f"--from-repo={REPOSITORY}", install) + self.assertIn("--assumeyes", install) + self.assertEqual(install[-1], package) + self.assertFalse(any(arg.startswith(("--repo=", "--enable-repo=", "--disable-repo=")) + for arg in install)) + self.assertIn("--installed", installed) + self.assertEqual(installed[installed.index("--queryformat") + 1], + "%{name}|%{version}|%{release}|%{arch}|%{from_repo}\\n") + gpg_options = {"--setopt=gpgcheck=1", "--setopt=*.gpgcheck=1", + f"--setopt={REPOSITORY}.gpgcheck=1"} + for command in queries: + self.assertTrue(gpg_options.issubset(command)) + self.assertFalse({"--nogpgcheck", "--no-gpgchecks"}.intersection(command)) + self.assertFalse(any("repo_gpgcheck=" in arg for arg in command)) + self.assertEqual(commands[-4], + ("rpm", "-qf", "--queryformat", "%{NAME}|%{VERSION}|%{RELEASE}|%{ARCH}\\n", + "/usr/bin/az")) + self.assertEqual(commands[-3:], [ + ("/usr/bin/az", "version", "--query", '"azure-cli"', "-o", "tsv"), + ("/usr/bin/az", "--version"), ("/usr/bin/az", "self-test"), + ]) + self.assertFalse(any(cmd[0] in ("yum", "sh", "sleep", "az") for cmd in commands)) + + def test_missing_or_wrong_available_package(self): + rows = ("", REPO_ROW.replace(VERSION, "2.0.0"), REPO_ROW.replace(RELEASE, "1.fc43"), + REPO_ROW.replace("x86_64", "noarch"), REPO_ROW.replace("azure-cli", "other-cli"), + REPO_ROW.replace(REPOSITORY, "fedora"), REPO_ROW.replace(REPOSITORY, "other-feed"), + REPO_ROW + "\n" + REPO_ROW) + for row in rows: + with self.subTest(row=row): + result, commands = self.run_script(success=False, STUB_AVAILABLE=row) + self.assertIn("not available", result.stderr) + self.assertEqual(len(commands), 3) + self.assertIn("--available", commands[-1]) + + def test_preinstalled_rpm_or_path_cli_is_rejected(self): + for override in ({"STUB_PREINSTALLED": "azure-cli"}, {"STUB_PATH_CLI": "1"}): + with self.subTest(override=override): + result, commands = self.run_script(success=False, **override) + self.assertIn("clean container", result.stderr) + self.assertEqual(len(commands), 1) + self.assertEqual(commands[0][0], "rpm") + + def test_installed_rpm_origin_and_owner_must_match(self): + for key, expected in (("STUB_RPM", RPM_ROW), ("STUB_ORIGIN", REPO_ROW), ("STUB_OWNER", RPM_ROW)): + rows = ("", expected.replace("azure-cli", "other-cli"), expected.replace(VERSION, "2.0.0"), + expected.replace(RELEASE, "1.el9"), expected.replace("x86_64", "aarch64"), + expected + "\n" + expected) + if key == "STUB_ORIGIN": + rows += (expected.replace(REPOSITORY, "fedora"), expected.replace(REPOSITORY, "@System")) + for row in rows: + with self.subTest(key=key, row=row): + _, commands = self.run_script(success=False, **{key: row}) + self.assertFalse(any(cmd[0] == "/usr/bin/az" for cmd in commands)) + + def test_cli_version_must_match(self): + for version in ("", "2.0.0", VERSION + "\n" + VERSION): + with self.subTest(version=version): + result, commands = self.run_script(success=False, STUB_CLI_VERSION=version) + self.assertIn("different CLI version", result.stderr) + self.assertEqual(commands[-1], + ("/usr/bin/az", "version", "--query", '"azure-cli"', "-o", "tsv")) + + def test_command_and_signature_failures_stop_immediately(self): + _, successful_commands = self.run_script() + stages = ("preflight", "arch", "available", "install", "rpm-query", "installed-origin", + "owner", "cli-version", "cli-banner", "self-test") + self.assertEqual(len(successful_commands), len(stages)) + for index, stage in enumerate(stages): + with self.subTest(stage=stage): + result, commands = self.run_script(success=False, STUB_FAIL=stage) + self.assertEqual(result.returncode, 41) + self.assertEqual(commands, successful_commands[:index + 1]) + result, commands = self.run_script(success=False, STUB_FAIL="signature") + self.assertEqual(result.returncode, 42) + self.assertIn("signature verification failed", result.stderr) + self.assertEqual(commands, successful_commands[:4]) + + def test_invalid_inputs_fail_before_any_commands(self): + for key, valid in (("RPM_REPOSITORY_ID", REPOSITORY), ("CLI_VERSION", VERSION), ("RPM_RELEASE", RELEASE)): + values = ("", "-option", valid + "*", valid + "?", "[" + valid + "]", valid + ",other", + valid + "\n", valid + "\r", valid + "\tother", valid + " other", valid + "/other", + valid + ";false") + if key != "RPM_REPOSITORY_ID": + values += (None,) + for value in values: + with self.subTest(key=key, value=value): + result, commands = self.run_script(success=False, **{key: value}) + self.assertIn(key, result.stderr) + self.assertEqual(commands, []) + for release in ("1.fc43", "1.el9"): + with self.subTest(release=release): + _, commands = self.run_script(success=False, RPM_RELEASE=release) + self.assertEqual(commands, []) + + def test_invalid_native_architecture(self): + for arch in ("", "%{_arch}", "*", "x86_64\naarch64"): + with self.subTest(arch=arch): + _, commands = self.run_script(success=False, STUB_ARCH=arch) + self.assertEqual(len(commands), 2) + self.assertEqual(commands[-1][0], "rpm") + + def test_postinstall_path_shadow_cannot_satisfy_verification(self): + _, commands = self.run_script(STUB_SHADOW_AFTER="1") + self.assertFalse(any(cmd[0] == "az" for cmd in commands)) + + def test_legacy_interface_when_selector_is_unset(self): + result, commands = self.run_script(RPM_REPOSITORY_ID=None, RPM_RELEASE=None) + self.assertIn("Latest package is verified.", result.stdout) + self.assertEqual(commands[0], ("rpm", "--import", "https://packages.microsoft.com/keys/microsoft.asc")) + self.assertEqual(commands[1][:2], ("sh", "-c")) + self.assertIn("gpgcheck=1", commands[1][2]) + self.assertIn("> /etc/yum.repos.d/azure-cli.repo", commands[1][2]) + self.assertEqual(commands[2:], [("yum", "install", "azure-cli", "-y"), ("az", "version")]) + + def test_legacy_retry_and_failure_behavior(self): + for version, attempts in (("", 4), ("2.0.0", 1)): + with self.subTest(version=version): + _, commands = self.run_script(success=False, RPM_REPOSITORY_ID=None, STUB_CLI_VERSION=version) + self.assertEqual(commands.count(("yum", "install", "azure-cli", "-y")), attempts) + self.assertEqual(commands.count(("sleep", "300")), 4 if version == "" else 0) + self.run_script(RPM_REPOSITORY_ID=None, STUB_FAIL="legacy-install") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release/rpm/verify_rpm_in_docker.sh b/scripts/release/rpm/verify_rpm_in_docker.sh index ae0a349c0a2..2af0d7f8401 100644 --- a/scripts/release/rpm/verify_rpm_in_docker.sh +++ b/scripts/release/rpm/verify_rpm_in_docker.sh @@ -2,6 +2,91 @@ # This script should be run in a docker to verify installing rpm package from the yum repository. +# Opt in on a clean Fedora 44 container with RPM_REPOSITORY_ID, CLI_VERSION +# (major.minor.patch), and RPM_RELEASE (e.g. 1.fc44). The repository and its +# signing keys must already be configured and approved by the maintainer. +# This verifies that configured repository, not public availability. +# Leave RPM_REPOSITORY_ID unset to retain legacy verification; empty is invalid. +if [[ ${RPM_REPOSITORY_ID+x} == x ]]; then + set -euo pipefail + + if [[ ! "$RPM_REPOSITORY_ID" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.:-]*$ ]]; then + echo "RPM_REPOSITORY_ID must be a single preconfigured repository ID." >&2 + exit 1 + fi + if [[ ! ${CLI_VERSION:-} =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "CLI_VERSION must be an explicit major.minor.patch version." >&2 + exit 1 + fi + if [[ ! ${RPM_RELEASE:-} =~ ^[0-9]+(\.[0-9]+)*\.fc44$ ]]; then + echo "RPM_RELEASE must be an explicit Fedora 44 release, e.g. 1.fc44." >&2 + exit 1 + fi + + # Unlike a failed rpm -q, an empty successful query is unambiguously absent. + preinstalled=$(rpm -qa azure-cli --queryformat '%{NAME}\n') + if [[ -n "$preinstalled" ]] || command -v az >/dev/null 2>&1; then + echo "Strict verification requires a clean container: no azure-cli RPM or az on PATH." >&2 + exit 1 + fi + + arch=$(rpm --eval '%{_arch}') + if [[ ! "$arch" =~ ^[a-zA-Z0-9_]+$ ]]; then + echo "Could not determine the native RPM architecture." >&2 + exit 1 + fi + package="azure-cli-${CLI_VERSION}-${RPM_RELEASE}.${arch}" + expected_rpm="azure-cli|${CLI_VERSION}|${RPM_RELEASE}|${arch}" + expected_repo="${expected_rpm}|${RPM_REPOSITORY_ID}" + rpm_format='%{NAME}|%{VERSION}|%{RELEASE}|%{ARCH}\n' + available_format='%{name}|%{version}|%{release}|%{arch}|%{repoid}\n' + installed_format='%{name}|%{version}|%{release}|%{arch}|%{from_repo}\n' + + # Override package signature checks even for repos that disable them. + # Leave each repository's metadata signature policy (repo_gpgcheck) intact. + dnf_options=( + --setopt=gpgcheck=1 + '--setopt=*.gpgcheck=1' + "--setopt=${RPM_REPOSITORY_ID}.gpgcheck=1" + ) + available=$(dnf5 "${dnf_options[@]}" --quiet --refresh "--repo=${RPM_REPOSITORY_ID}" \ + repoquery --available --queryformat "$available_format" "$package") + if [[ "$available" != "$expected_repo" ]]; then + echo "The exact Fedora 44 package is not available from RPM_REPOSITORY_ID." >&2 + exit 1 + fi + + # --from-repo restricts the requested package, not its distro dependencies. + dnf5 "${dnf_options[@]}" install --assumeyes "--from-repo=${RPM_REPOSITORY_ID}" "$package" + + installed=$(rpm -q --queryformat "$rpm_format" azure-cli) + if [[ "$installed" != "$expected_rpm" ]]; then + echo "Installed azure-cli RPM does not match the expected name/version/release/architecture." >&2 + exit 1 + fi + installed_repo=$(dnf5 "${dnf_options[@]}" --quiet repoquery --installed \ + --queryformat "$installed_format" azure-cli) + if [[ "$installed_repo" != "$expected_repo" ]]; then + echo "Installed azure-cli does not match the expected package and repository origin." >&2 + exit 1 + fi + owner=$(rpm -qf --queryformat "$rpm_format" /usr/bin/az) + if [[ "$owner" != "$expected_rpm" ]]; then + echo "/usr/bin/az is not owned by the expected azure-cli RPM." >&2 + exit 1 + fi + + actual_version=$(/usr/bin/az version --query '"azure-cli"' -o tsv) + if [[ "$actual_version" != "$CLI_VERSION" ]]; then + echo "/usr/bin/az reports a different CLI version." >&2 + exit 1 + fi + /usr/bin/az --version + /usr/bin/az self-test + echo "Verified ${package} from configured repository ${RPM_REPOSITORY_ID}." + exit 0 +fi + rpm --import https://packages.microsoft.com/keys/microsoft.asc sh -c 'echo -e "[azure-cli] name=Azure CLI