diff --git a/doc/install_troubleshooting.md b/doc/install_troubleshooting.md index bd011b23c91..4d3e61127c3 100644 --- a/doc/install_troubleshooting.md +++ b/doc/install_troubleshooting.md @@ -103,6 +103,30 @@ This can be resolved by installing the CLI in a higher directory to prevent reac See [#1221](https://github.com/Azure/azure-cli/issues/1221#issuecomment-258290204) +Windows - CLI is blocked by Device Guard / WDAC (Windows Defender Application Control) after `az upgrade` +----------------------------------------------------------------------------------------------------------- + +On machines that enforce Device Guard / Windows Defender Application Control (WDAC) or similar code +integrity policies (for example, Azure Local/Azure Stack HCI cluster nodes), the bundled `python.exe` +shipped with the Azure CLI MSI may be blocked from running if it doesn't meet the signing requirements +configured by the enforced policy. This can make a previously working Azure CLI installation unusable +immediately after running `az upgrade`. + +If you observe Windows Code Integrity events (such as Event ID 3033 or 3077) referencing +`Microsoft SDKs\Azure\CLI2\python.exe` after an upgrade, or `az version`/`az` fails to start with a message +like `'...\python.exe' was blocked by your organization's Device Guard policy`: + +* Reinstall the previous, working Azure CLI MSI to restore functionality. Installers for previous versions + are available at https://learn.microsoft.com/cli/azure/release-notes-azure-cli. +* On machines with enforced code integrity policies, test `az upgrade` in a non-production/staging + environment first, and confirm `az version` still runs successfully before rolling the upgrade out more + broadly (for example, across an Azure Local cluster). +* Contact your policy administrator to allow the new binary, or update the enforced policy, if you need to + use the newer version. + +See [#33919](https://github.com/Azure/azure-cli/issues/33919) + + Ubuntu 12.04 LTS - Known warning -------------------------------- diff --git a/scripts/release/rpm/azure-cli.spec b/scripts/release/rpm/azure-cli.spec index 91e01b15f94..59fb4e27953 100644 --- a/scripts/release/rpm/azure-cli.spec +++ b/scripts/release/rpm/azure-cli.spec @@ -66,15 +66,19 @@ rm %{buildroot}%{cli_lib_dir}/pyvenv.cfg # https://github.com/Azure/azure-cli/pull/20061 # - Fedora/CentOS/RedHat: relative path 'lib64' # - Azure Linux: absolute path '/usr/lib' -# The only solution left is to hard-code 'lib64' as we only release 64-bit RPM packages. +# Azure Linux 4.0 removed the /usr/lib64 -> /usr/lib symlink that AZL3 carried, so we can no longer +# hard-code 'lib64'. Instead, probe lib64 first (Fedora/RHEL) then fall back to lib (Azure Linux). mkdir -p %{buildroot}%{_bindir} python_version=$(ls %{buildroot}%{cli_lib_dir}/lib/ | head -n 1) # We make %{python_cmd} the default executable, but if there is a more precise match, such as python3.9, we prefer that. printf "#!/usr/bin/env bash -bin_dir=\`cd \"\$(dirname \"\$BASH_SOURCE[0]\")\"; pwd\` +bin_dir=\`cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\"; pwd\` python_cmd=%{python_cmd} if command -v ${python_version} &>/dev/null; then python_cmd=${python_version}; fi -AZ_INSTALLER=RPM PYTHONPATH=\"\$bin_dir/../lib64/az/lib/${python_version}/site-packages\" \$python_cmd -sm azure.cli \"\$@\" +for _az_pypath in \"\$bin_dir/../lib64/az/lib/${python_version}/site-packages\" \"\$bin_dir/../lib/az/lib/${python_version}/site-packages\"; do + [ -d \"\$_az_pypath\" ] && break +done +AZ_INSTALLER=RPM PYTHONPATH=\"\$_az_pypath\" \$python_cmd -sm azure.cli \"\$@\" " > %{buildroot}%{_bindir}/az rm %{buildroot}%{cli_lib_dir}/bin/python* %{buildroot}%{cli_lib_dir}/bin/pip* diff --git a/scripts/release/rpm/test_azurelinux_in_docker.sh b/scripts/release/rpm/test_azurelinux_in_docker.sh index 46ae88086fc..7631b22abe7 100644 --- a/scripts/release/rpm/test_azurelinux_in_docker.sh +++ b/scripts/release/rpm/test_azurelinux_in_docker.sh @@ -23,11 +23,18 @@ python -m pip install --upgrade "setuptools<81" # Ref https://docs.fedoraproject.org/en-US/fedora/latest/release-notes/developers/Development_Python/#_pipsetup_py_installation_with_prefix export RPM_BUILD_ROOT=/ -pip install pytest --prefix /usr/lib64/az -pip install pytest-xdist --prefix /usr/lib64/az -pip install pytest-forked --prefix /usr/lib64/az +# Detect where azure-cli was installed (supports both /usr/lib64/az on Fedora/RHEL and /usr/lib/az on Azure Linux) +if [ -d /usr/lib/az ]; then + AZ_LIB_DIR=/usr/lib/az +else + AZ_LIB_DIR=/usr/lib64/az +fi -find /azure-cli/artifacts/build -name "azure_cli_testsdk*" | xargs pip install --prefix /usr/lib64/az --upgrade --ignore-installed -find /azure-cli/artifacts/build -name "azure_cli_fulltest*" | xargs pip install --prefix /usr/lib64/az --upgrade --ignore-installed --no-deps +pip install pytest --prefix "$AZ_LIB_DIR" +pip install pytest-xdist --prefix "$AZ_LIB_DIR" +pip install pytest-forked --prefix "$AZ_LIB_DIR" -python /azure-cli/scripts/release/rpm/test_rpm_package.py +find /azure-cli/artifacts/build -name "azure_cli_testsdk*" | xargs pip install --prefix "$AZ_LIB_DIR" --upgrade --ignore-installed +find /azure-cli/artifacts/build -name "azure_cli_fulltest*" | xargs pip install --prefix "$AZ_LIB_DIR" --upgrade --ignore-installed --no-deps + +AZ_LIB_DIR="$AZ_LIB_DIR" python /azure-cli/scripts/release/rpm/test_rpm_package.py diff --git a/scripts/release/rpm/test_rpm_package.py b/scripts/release/rpm/test_rpm_package.py index 8b055664f4d..269050ef836 100644 --- a/scripts/release/rpm/test_rpm_package.py +++ b/scripts/release/rpm/test_rpm_package.py @@ -7,11 +7,16 @@ import sys import subprocess -python_version = os.listdir('/usr/lib64/az/lib/')[0] -root_dir = f'/usr/lib64/az/lib/{python_version}/site-packages/azure/cli/command_modules' +# Support both /usr/lib64/az (Fedora/RHEL) and /usr/lib/az (Azure Linux). +# AZ_LIB_DIR can be set explicitly by the caller (e.g. test_azurelinux_in_docker.sh); +# fall back to auto-detection based on which directory was actually created by the RPM. +az_lib_base = os.environ.get('AZ_LIB_DIR') or ('/usr/lib64/az' if os.path.isdir('/usr/lib64/az') else '/usr/lib/az') + +python_version = os.listdir(f'{az_lib_base}/lib/')[0] +root_dir = f'{az_lib_base}/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_base_cmd = f'PYTHONPATH={az_lib_base}/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 diff --git a/src/azure-cli/HISTORY.rst b/src/azure-cli/HISTORY.rst index 1437a140766..b61a4c408ab 100644 --- a/src/azure-cli/HISTORY.rst +++ b/src/azure-cli/HISTORY.rst @@ -9,6 +9,7 @@ Release History **Core** * Allow SSH certificate flow in Cloud Shell (#33860) +* `az upgrade`: Warn about Device Guard / WDAC policies potentially blocking the CLI after a Windows MSI upgrade (#33919) 2.89.0 ++++++ diff --git a/src/azure-cli/azure/cli/command_modules/util/custom.py b/src/azure-cli/azure/cli/command_modules/util/custom.py index 138e1f51407..33eaa3d6107 100644 --- a/src/azure-cli/azure/cli/command_modules/util/custom.py +++ b/src/azure-cli/azure/cli/command_modules/util/custom.py @@ -218,6 +218,9 @@ def _upgrade_on_windows(): from azure.cli.core.util import rmtree_with_retry + # MSI exit codes that indicate a successful install (3010/1641 = success, restart required). + _MSI_SUCCESS_CODES = {0, 1641, 3010} + if platform.architecture()[0] == '32bit': msi_url = 'https://aka.ms/installazurecliwindows' else: @@ -231,9 +234,39 @@ def _upgrade_on_windows(): msi_path = _download_from_url(msi_url, msi_dir) - subprocess.Popen(['msiexec.exe', '/i', msi_path]) - logger.warning("Installation started. Please complete the upgrade in the opened window.\nTo update extensions, " - "please run `az upgrade` again after completing the upgrade.") + # Run msiexec in passive mode (progress bar, no user interaction) and wait for completion. + # This allows us to verify the new installation afterwards. + logger.warning("Installing Azure CLI MSI. A progress window will appear — please wait for it to finish.") + result = subprocess.run(['msiexec.exe', '/i', msi_path, '/passive'], check=False) + + if result.returncode not in _MSI_SUCCESS_CODES: + logger.warning( + "MSI installation failed (exit code %d). The MSI file is saved at '%s'. " + "You can install it manually or re-run `az upgrade`.", + result.returncode, msi_path) + sys.exit(result.returncode) + + if result.returncode in (1641, 3010): + logger.warning("The upgrade was applied but a system restart is required before the new CLI is active.") + + # Verify that the new installation can be launched. On machines enforcing Device Guard / + # Windows Defender Application Control (WDAC) policies the newly installed python.exe may be + # blocked, leaving the CLI unusable even though the MSI reported success. + try: + subprocess.check_output('az version -o json', shell=True, timeout=30) + logger.warning("Upgrade finished. Run `az upgrade` again to update any installed extensions.") + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError, OSError): + logger.warning( + "The MSI was installed successfully, but running 'az version' failed. " + "This likely means that Device Guard / Windows Defender Application Control (WDAC) " + "or a similar code integrity policy is blocking the newly installed CLI binary.\n" + "To restore the previous working CLI, reinstall the previous MSI version.\n" + "The new MSI is still available at '%s' if you need to retry.\n" + "See https://github.com/Azure/azure-cli/blob/dev/doc/install_troubleshooting.md " + "for troubleshooting steps.", + msi_path) + sys.exit(1) + sys.exit(0) diff --git a/src/azure-cli/azure/cli/command_modules/util/tests/latest/test_upgrade.py b/src/azure-cli/azure/cli/command_modules/util/tests/latest/test_upgrade.py new file mode 100644 index 00000000000..2d2e1b26455 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/util/tests/latest/test_upgrade.py @@ -0,0 +1,126 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import subprocess +import unittest +from unittest import mock + +from azure.cli.command_modules.util.custom import _upgrade_on_windows + +_MSI_PATH = 'C:\\temp\\azure-cli-msi\\azure-cli.msi' + + +class UpgradeOnWindowsTest(unittest.TestCase): + + # ------------------------------------------------------------------ + # successful upgrade — az version passes + # ------------------------------------------------------------------ + + def test_successful_upgrade_exits_zero(self): + """When msiexec succeeds and az version passes, exit code is 0.""" + with mock.patch('platform.architecture', return_value=('64bit', '')), \ + mock.patch('azure.cli.command_modules.util.custom._download_from_url', + return_value=_MSI_PATH), \ + mock.patch('azure.cli.core.util.rmtree_with_retry'), \ + mock.patch('azure.cli.command_modules.util.custom.logger'), \ + mock.patch('subprocess.run', + return_value=mock.Mock(returncode=0)) as run_mock, \ + mock.patch('subprocess.check_output', return_value=b'{}'): + with self.assertRaises(SystemExit) as cm: + _upgrade_on_windows() + + self.assertEqual(cm.exception.code, 0) + run_mock.assert_called_once_with( + ['msiexec.exe', '/i', _MSI_PATH, '/passive'], check=False) + + # ------------------------------------------------------------------ + # WDAC blocking — az version fails after successful msiexec + # ------------------------------------------------------------------ + + def test_wdac_blocking_exits_nonzero_and_warns(self): + """When msiexec succeeds but az version fails, exit 1 and warn about WDAC.""" + with mock.patch('platform.architecture', return_value=('64bit', '')), \ + mock.patch('azure.cli.command_modules.util.custom._download_from_url', + return_value=_MSI_PATH), \ + mock.patch('azure.cli.core.util.rmtree_with_retry'), \ + mock.patch('azure.cli.command_modules.util.custom.logger') as logger_mock, \ + mock.patch('subprocess.run', + return_value=mock.Mock(returncode=0)), \ + mock.patch('subprocess.check_output', + side_effect=subprocess.CalledProcessError(1, 'az version -o json')): + with self.assertRaises(SystemExit) as cm: + _upgrade_on_windows() + + self.assertEqual(cm.exception.code, 1) + warning_messages = [call_args[0][0] for call_args in logger_mock.warning.call_args_list] + self.assertTrue( + any('Device Guard' in m and 'WDAC' in m for m in warning_messages), + "Expected a WDAC/Device Guard warning; got: {}".format(warning_messages)) + + # ------------------------------------------------------------------ + # msiexec failure + # ------------------------------------------------------------------ + + def test_msiexec_failure_exits_with_msi_exit_code(self): + """When msiexec returns a non-success exit code, we exit with that code.""" + with mock.patch('platform.architecture', return_value=('64bit', '')), \ + mock.patch('azure.cli.command_modules.util.custom._download_from_url', + return_value=_MSI_PATH), \ + mock.patch('azure.cli.core.util.rmtree_with_retry'), \ + mock.patch('azure.cli.command_modules.util.custom.logger'), \ + mock.patch('subprocess.run', + return_value=mock.Mock(returncode=1603)): + with self.assertRaises(SystemExit) as cm: + _upgrade_on_windows() + + self.assertEqual(cm.exception.code, 1603) + + # ------------------------------------------------------------------ + # restart-required exit codes (1641 / 3010) + # ------------------------------------------------------------------ + + def test_restart_required_warns_and_exits_zero(self): + """When msiexec returns 3010 (restart required) and az version succeeds, exit 0.""" + with mock.patch('platform.architecture', return_value=('64bit', '')), \ + mock.patch('azure.cli.command_modules.util.custom._download_from_url', + return_value=_MSI_PATH), \ + mock.patch('azure.cli.core.util.rmtree_with_retry'), \ + mock.patch('azure.cli.command_modules.util.custom.logger') as logger_mock, \ + mock.patch('subprocess.run', + return_value=mock.Mock(returncode=3010)), \ + mock.patch('subprocess.check_output', return_value=b'{}'): + with self.assertRaises(SystemExit) as cm: + _upgrade_on_windows() + + self.assertEqual(cm.exception.code, 0) + warning_messages = [call_args[0][0] for call_args in logger_mock.warning.call_args_list] + self.assertTrue( + any('restart' in m.lower() for m in warning_messages), + "Expected a restart warning for exit code 3010; got: {}".format(warning_messages)) + + # ------------------------------------------------------------------ + # 32-bit architecture uses 32-bit MSI URL + # ------------------------------------------------------------------ + + def test_32bit_uses_correct_msi_url(self): + """On a 32-bit architecture the 32-bit MSI URL is used.""" + with mock.patch('platform.architecture', return_value=('32bit', '')), \ + mock.patch('azure.cli.command_modules.util.custom._download_from_url', + return_value=_MSI_PATH) as download_mock, \ + mock.patch('azure.cli.core.util.rmtree_with_retry'), \ + mock.patch('azure.cli.command_modules.util.custom.logger'), \ + mock.patch('subprocess.run', + return_value=mock.Mock(returncode=0)), \ + mock.patch('subprocess.check_output', return_value=b'{}'): + with self.assertRaises(SystemExit): + _upgrade_on_windows() + + url_used = download_mock.call_args[0][0] + self.assertIn('installazurecliwindows', url_used) + self.assertNotIn('x64', url_used) + + +if __name__ == '__main__': + unittest.main()