From 473b35f4f7e14cddbb8b79107f981bb73f066aae Mon Sep 17 00:00:00 2001 From: Fuming Zhang Date: Wed, 9 Sep 2026 06:05:03 +0000 Subject: [PATCH 1/4] {AKS} Fix live scenario contracts and proxy bootstrap Use supported ALB arguments, verify persisted state after void reimage actions, handle precise capability gates and operation preemption, and replace the unavailable legacy proxy package repository with Ubuntu 24.04 Squid. Require bounded proxy readiness before creating AKS. Validation: 55 tests and 21 subtests passed; 388 scenarios collected. Ubuntu 24.04 container bootstrap returned HTTP 200 through all four HTTP/HTTPS proxy paths. Syntax, style and diff checks passed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tests/latest/data/setup_proxy.sh | 37 ++-- .../tests/latest/test_aks_commands.py | 116 ++++++++---- .../latest/test_aks_provisioning_retry.py | 178 ++++++++++++++++++ 3 files changed, 273 insertions(+), 58 deletions(-) diff --git a/src/aks-preview/azext_aks_preview/tests/latest/data/setup_proxy.sh b/src/aks-preview/azext_aks_preview/tests/latest/data/setup_proxy.sh index 879a0988688..9d0765970ae 100644 --- a/src/aks-preview/azext_aks_preview/tests/latest/data/setup_proxy.sh +++ b/src/aks-preview/azext_aks_preview/tests/latest/data/setup_proxy.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -set -x +set -euxo pipefail echo "setting up" WORKDIR="${1:-$(mktemp -d)}" @@ -7,22 +7,14 @@ echo "setting up ${WORKDIR}" pushd "$WORKDIR" -apt update -y && apt install -y apt-transport-https curl gnupg make gcc < /dev/null - -# add diladele apt key -wget -qO - https://packages.diladele.com/diladele_pub.asc | apt-key add - - -# add new repo -tee /etc/apt/sources.list.d/squid413-ubuntu20.diladele.com.list < Date: Wed, 9 Sep 2026 06:09:05 +0000 Subject: [PATCH 2/4] {AKS} Keep preview monitoring profiles consistent Apply the canonical Container Insights synchronization fix to aks-preview's separate addon and flow-log implementations. Preserve other Azure Monitor settings and add wire-payload regressions plus a Pending release note. Validation: 69 targeted preview tests and 23 subtests passed across the scenario, custom-command, and flow-log suites; product style and syntax checks passed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/aks-preview/HISTORY.rst | 1 + src/aks-preview/azext_aks_preview/custom.py | 13 ++++ .../managed_cluster_decorator.py | 5 ++ .../tests/latest/test_custom.py | 71 +++++++++++++++++++ 4 files changed, 90 insertions(+) diff --git a/src/aks-preview/HISTORY.rst b/src/aks-preview/HISTORY.rst index e20aa860346..a80895e4413 100644 --- a/src/aks-preview/HISTORY.rst +++ b/src/aks-preview/HISTORY.rst @@ -12,6 +12,7 @@ To release a new version, please select a new version number (usually plus 1 to Pending +++++++ * `az aks alert-config add`: Reject an empty `--name` before looking up existing configurations instead of reporting that it already exists. +* `az aks enable-addons`, `az aks disable-addons` and `az aks update`: Keep Container Insights monitoring and container network log settings consistent with the legacy monitoring addon when updating clusters. 22.0.0b7 +++++++++ diff --git a/src/aks-preview/azext_aks_preview/custom.py b/src/aks-preview/azext_aks_preview/custom.py index f925723cec4..78986c60b06 100644 --- a/src/aks-preview/azext_aks_preview/custom.py +++ b/src/aks-preview/azext_aks_preview/custom.py @@ -4085,6 +4085,19 @@ def _update_addons(cmd, # pylint: disable=too-many-branches,too-many-statements raise CLIError(f"The addon {addon} is not installed.") addon_profiles[addon].config = None addon_profiles[addon].enabled = enable + if addon == CONST_MONITORING_ADDON_NAME: + monitor_profile = getattr(instance, "azure_monitor_profile", None) + if getattr(monitor_profile, "container_insights", None) is not None: + # Reset canonical monitoring values along with the legacy addon config. + ContainerInsights = cmd.get_models( + "ManagedClusterAzureMonitorProfileContainerInsights", + resource_type=CUSTOM_MGMT_AKS_PREVIEW, + operation_group="managed_clusters", + ) + monitor_profile.container_insights = ContainerInsights( + enabled=enable, + log_analytics_workspace_resource_id=workspace_resource_id if enable else None, + ) instance.addon_profiles = addon_profiles diff --git a/src/aks-preview/azext_aks_preview/managed_cluster_decorator.py b/src/aks-preview/azext_aks_preview/managed_cluster_decorator.py index a9a88b3c8e8..7fef93edcb7 100644 --- a/src/aks-preview/azext_aks_preview/managed_cluster_decorator.py +++ b/src/aks-preview/azext_aks_preview/managed_cluster_decorator.py @@ -6662,6 +6662,11 @@ def update_monitoring_profile_flow_logs(self, mc: ManagedCluster) -> ManagedClus config = monitoring_addon_profile.config or {} config["enableRetinaNetworkFlags"] = str(container_network_logs_enabled) mc.addon_profiles[monitoring_addon_key].config = config + container_insights = getattr(mc.azure_monitor_profile, "container_insights", None) + if container_insights is not None: + container_insights.container_network_logs = ( + "Enabled" if container_network_logs_enabled else "Disabled" + ) # When enabling CNL, the DCR must be updated to add the high-scale stream. # Set the postprocessing intermediate so that the update path calls ensure_container_insights. diff --git a/src/aks-preview/azext_aks_preview/tests/latest/test_custom.py b/src/aks-preview/azext_aks_preview/tests/latest/test_custom.py index 8d2e76a7a08..4fb6eba9903 100644 --- a/src/aks-preview/azext_aks_preview/tests/latest/test_custom.py +++ b/src/aks-preview/azext_aks_preview/tests/latest/test_custom.py @@ -2,6 +2,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +import json import unittest from unittest.mock import Mock, patch @@ -231,6 +232,76 @@ def test_aks_enable_addons_virtual_node_with_none_agent_pool_profiles(self): self.assertNotIn("NoneType", str(type(e))) +class TestMonitoringProfileConsistency(unittest.TestCase): + @staticmethod + def _cluster(): + from azext_aks_preview.vendored_sdks.azure_mgmt_preview_aks import models + + return models.ManagedCluster( + location="westus2", + addon_profiles={"omsagent": models.ManagedClusterAddonProfile( + enabled=True, + config={"enableRetinaNetworkFlags": "true", "useAADAuth": "true"}, + )}, + azure_monitor_profile=models.ManagedClusterAzureMonitorProfile( + container_insights=models.ManagedClusterAzureMonitorProfileContainerInsights( + enabled=True, container_network_logs="Enabled", + ), + metrics=models.ManagedClusterAzureMonitorProfileMetrics(enabled=True), + ), + ) + + @staticmethod + def _wire_properties(cluster): + from azext_aks_preview.vendored_sdks.azure_mgmt_preview_aks import ContainerServiceClient + + class RequestCaptured(Exception): + pass + + with ContainerServiceClient(Mock(), "sub") as client: + with patch.object(client._client._pipeline, "run", side_effect=RequestCaptured) as send: + try: + client.managed_clusters.begin_create_or_update("rg", "cluster", cluster) + except RequestCaptured: + return json.loads(send.call_args.args[0].body)["properties"] + raise AssertionError("The preview SDK did not construct the managed cluster PUT") + + def test_disable_and_reenable_monitoring_updates_both_wire_profiles(self): + from azext_aks_preview.custom import _update_addons + + register_aks_preview_resource_type() + cmd = MockCmd(MockCLI()) + cluster = self._cluster() + workspace = "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.OperationalInsights/workspaces/workspace" + for enable in (False, True): + with self.subTest(enable=enable): + _update_addons(cmd, cluster, "sub", "rg", "cluster", "monitoring", enable, + workspace_resource_id=workspace) + payload = self._wire_properties(cluster) + insights = payload["azureMonitorProfile"]["containerInsights"] + self.assertEqual(insights["enabled"], enable) + self.assertEqual(payload["addonProfiles"]["omsagent"]["enabled"], enable) + self.assertNotEqual(insights.get("containerNetworkLogs"), "Enabled") + if enable: + self.assertEqual(insights["logAnalyticsWorkspaceResourceId"], workspace) + self.assertTrue(payload["azureMonitorProfile"]["metrics"]["enabled"]) + + def test_disable_flow_logs_updates_both_wire_profiles(self): + from azext_aks_preview.managed_cluster_decorator import AKSPreviewManagedClusterUpdateDecorator + + register_aks_preview_resource_type() + cluster = self._cluster() + decorator = AKSPreviewManagedClusterUpdateDecorator( + MockCmd(MockCLI()), Mock(), {"disable_container_network_logs": True}, CUSTOM_MGMT_AKS_PREVIEW, + ) + decorator.context.attach_mc(cluster) + decorator.update_monitoring_profile_flow_logs(cluster) + payload = self._wire_properties(cluster) + self.assertEqual(payload["azureMonitorProfile"]["containerInsights"]["containerNetworkLogs"], "Disabled") + self.assertEqual(payload["addonProfiles"]["omsagent"]["config"]["enableRetinaNetworkFlags"].lower(), "false") + self.assertTrue(payload["azureMonitorProfile"]["metrics"]["enabled"]) + + class TestAksAgentPoolGetBootstrapData(unittest.TestCase): def setUp(self): register_aks_preview_resource_type() From f0bb43e3147f64479ba0e9a63c7eaf3c769fef51 Mon Sep 17 00:00:00 2001 From: Fuming Zhang Date: Wed, 9 Sep 2026 09:23:05 +0000 Subject: [PATCH 3/4] {AKS} Accept service-normalized flow-log flag casing Assert true/false values for legacy string flags without depending on service capitalization. Preserve create, disable, and re-enable checks. Validation: affected test_aks_create_acns_with_flow_logs passed end-to-end LIVE (752.46 seconds). Original True-versus-true failure retained in validation evidence. No unit or mocked run substitutes for this result. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tests/latest/test_aks_commands.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/aks-preview/azext_aks_preview/tests/latest/test_aks_commands.py b/src/aks-preview/azext_aks_preview/tests/latest/test_aks_commands.py index b09813fdf97..f0c7e8a2860 100644 --- a/src/aks-preview/azext_aks_preview/tests/latest/test_aks_commands.py +++ b/src/aks-preview/azext_aks_preview/tests/latest/test_aks_commands.py @@ -21787,7 +21787,9 @@ def test_aks_create_acns_with_flow_logs( self.check("networkProfile.advancedNetworking.observability.enabled", True), self.check("networkProfile.advancedNetworking.security.enabled", True), self.check("addonProfiles.omsagent.enabled", True), - self.check("addonProfiles.omsagent.config.enableRetinaNetworkFlags", "True"), + self.check( + "contains(['True', 'true'], addonProfiles.omsagent.config.enableRetinaNetworkFlags)", True + ), ], ).get_output_in_json() @@ -21812,7 +21814,9 @@ def test_aks_create_acns_with_flow_logs( disable_cmd, checks=[ self.check("provisioningState", "Succeeded"), - self.check("addonProfiles.omsagent.config.enableRetinaNetworkFlags", "False"), + self.check( + "contains(['False', 'false'], addonProfiles.omsagent.config.enableRetinaNetworkFlags)", True + ), ], ) @@ -21822,7 +21826,9 @@ def test_aks_create_acns_with_flow_logs( enable_cmd_update, checks=[ self.check("provisioningState", "Succeeded"), - self.check("addonProfiles.omsagent.config.enableRetinaNetworkFlags", "True"), + self.check( + "contains(['True', 'true'], addonProfiles.omsagent.config.enableRetinaNetworkFlags)", True + ), ], ) From 6a8f0684bed199001d368db11a24bb9b843d48a1 Mon Sep 17 00:00:00 2001 From: Fuming Zhang Date: Thu, 17 Sep 2026 03:04:33 +0000 Subject: [PATCH 4/4] {AKS} Repair live DNS, KMS, metrics and backup dependencies Scope private DNS role-propagation retries, rewrite secrets before private KMS key rotation, resume metrics configuration after retried creates, and use compatible reversible-soft-delete cleanup for isolated backup vaults. The affected scenarios passed in live mode before this commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tests/latest/test_aks_commands.py | 78 +++++++- .../latest/test_aks_provisioning_retry.py | 174 ++++++++++++++++++ 2 files changed, 245 insertions(+), 7 deletions(-) diff --git a/src/aks-preview/azext_aks_preview/tests/latest/test_aks_commands.py b/src/aks-preview/azext_aks_preview/tests/latest/test_aks_commands.py index f0c7e8a2860..e2d5332f6e5 100644 --- a/src/aks-preview/azext_aks_preview/tests/latest/test_aks_commands.py +++ b/src/aks-preview/azext_aks_preview/tests/latest/test_aks_commands.py @@ -256,6 +256,10 @@ def _execute_with_transient_conflict_retry(self, command, expect_failure): ): show_command = self._build_show_command_for_already_existing_resource(command) if show_command is not None: + recovery_command = getattr(self, "_retried_create_recovery_command", None) + if recovery_command and re.match(r"^aks\s+create\b", command.strip()): + logging.warning("Resuming metrics configuration after a retried cluster create.") + return self._execute_with_transient_conflict_retry(recovery_command, False) logging.warning( "Resource already exists after a retried create/add; the earlier " "attempt's async operation likely already succeeded server-side. " @@ -292,7 +296,10 @@ def _execute_with_transient_conflict_retry(self, command, expect_failure): return show_result if ( expect_failure or - not self._is_transient_operation_conflict(ex) or + not ( + self._is_transient_operation_conflict(ex) or + self._is_private_dns_role_assignment_pending(command, ex) + ) or attempt == max_retries - 1 ): raise @@ -309,6 +316,48 @@ def _execute_with_transient_conflict_retry(self, command, expect_failure): raise AssertionError("unreachable") + def _cmd_with_retried_create_recovery(self, command, recovery_command, checks=None): + previous_command = getattr(self, "_retried_create_recovery_command", None) + self._retried_create_recovery_command = self._apply_kwargs(recovery_command) + try: + return self.cmd(command, checks=checks) + finally: + self._retried_create_recovery_command = previous_command + + def _is_private_dns_role_assignment_pending(self, command, ex): + zone_id = getattr(self, "_private_dns_role_assignment_scope", None) + if not zone_id or not re.match(r"^aks\s+create\b", command.strip()): + return False + requested_zone = self._extract_cli_option(command, "--private-dns-zone") + message = str(ex).casefold() + return ( + requested_zone is not None and requested_zone.casefold() == zone_id.casefold() and + "(resourcemissingpermissionerror)" in message and + f"resource {zone_id.casefold()}." in message and + "not allowed for action microsoft.network/privatednszones/read" in message + ) + + def _cmd_with_private_dns_role_assignment_retry(self, command, zone_id, checks=None): + # Only the caller that just granted this zone's role may retry propagation. + previous_scope = getattr(self, "_private_dns_role_assignment_scope", None) + self._private_dns_role_assignment_scope = zone_id + try: + return self.cmd(command, checks=checks) + finally: + self._private_dns_role_assignment_scope = previous_scope + + def _reencrypt_kms_secrets(self): + if not self.is_live: + return + # Private-vault rotation requires every secret to use the current key first. + self.cmd( + "aks command invoke --resource-group={resource_group} --name={name} " + "--command \"bash -o pipefail -c " + "'kubectl get secrets --all-namespaces -o json | kubectl replace -f -'\" " + "--output json", + checks=[self.check("provisioningState", "Succeeded"), self.check("exitCode", 0)], + ) + def _refetch_settled_aks_result(self, resource_id, fallback_result): from azure.cli.testsdk.base import execute @@ -10285,8 +10334,9 @@ def test_aks_create_fqdn_subdomain(self, resource_group, resource_group_location "--enable-private-cluster --private-dns-zone={zone_id} --enable-managed-identity --assign-identity {identity_resource_id} " "--ssh-key-value={ssh_key_value}" ) - self.cmd( + self._cmd_with_private_dns_role_assignment_retry( create_cmd, + zone_id, checks=[ self.exists("privateFqdn"), self.exists("fqdnSubdomain"), @@ -13821,6 +13871,7 @@ def test_aks_create_with_azurekeyvaultkms_private_key_vault( "--azure-keyvault-kms-key-vault-network-access=Private --azure-keyvault-kms-key-vault-resource-id {kv_resource_id} " "-o json" ) + self._reencrypt_kms_secrets() self.cmd( update_cmd, checks=[ @@ -14144,6 +14195,7 @@ def test_aks_create_with_azurekeyvaultkms_private_cluster_v1_private_key_vault( "--azure-keyvault-kms-key-vault-network-access=Private --azure-keyvault-kms-key-vault-resource-id {kv_resource_id} " "-o json" ) + self._reencrypt_kms_secrets() self.cmd( update_cmd, checks=[ @@ -16430,12 +16482,17 @@ def _cleanup_backup(self, vault_name): # 1. Disable immutability + soft-delete on the vault so that BIs # with active recovery points can be force-deleted. + # Use the API that supports reversible soft delete, as AKS vault creation does. try: self.cmd( - "dataprotection backup-vault update " - "-g {backup_rg} --vault-name {vault_name} " + "resource update --resource-group {backup_rg} --name {vault_name} " + "--resource-type Microsoft.DataProtection/backupVaults --api-version 2025-07-01 " "--set properties.securitySettings.immutabilitySettings.state=Disabled " - "properties.securitySettings.softDeleteSettings.state=Off" + "properties.securitySettings.softDeleteSettings.state=Off", + checks=[ + self.check("properties.securitySettings.immutabilitySettings.state", "Disabled"), + self.check("properties.securitySettings.softDeleteSettings.state", "Off"), + ], ) except Exception as ex: # pylint: disable=broad-except logging.warning( @@ -17399,19 +17456,26 @@ def test_aks_create_with_control_plane_metrics( "node_vm_size": node_vm_size, } ) + self.kwargs["amw_id"] = self._create_azure_monitor_workspace(resource_group_location) # create: --enable-azure-monitor-metrics + --enable-control-plane-metrics create_cmd = ( "aks create --resource-group={resource_group} --name={name} --location={location} " "--ssh-key-value={ssh_key_value} --node-vm-size={node_vm_size} --enable-managed-identity " - "--enable-azure-monitor-metrics --enable-control-plane-metrics --output=json" + "--enable-azure-monitor-metrics --enable-control-plane-metrics " + "--azure-monitor-workspace-resource-id={amw_id} --output=json" ) # NOTE: ``--enable-control-plane-metrics`` on create is intentionally deferred to a # postprocessing PUT (after DCRA creation) to avoid scheduling the CCP pod before its # DCRA exists. The create response may therefore reflect the pre-flip state; assert # the final state via ``aks show`` after the cluster settles. - self.cmd( + self._cmd_with_retried_create_recovery( create_cmd, + recovery_command=( + "aks update --resource-group={resource_group} --name={name} " + "--enable-azure-monitor-metrics --enable-control-plane-metrics " + "--azure-monitor-workspace-resource-id={amw_id}" + ), checks=[ self.check("provisioningState", "Succeeded"), self.check("azureMonitorProfile.metrics.enabled", True), diff --git a/src/aks-preview/azext_aks_preview/tests/latest/test_aks_provisioning_retry.py b/src/aks-preview/azext_aks_preview/tests/latest/test_aks_provisioning_retry.py index 86501134f28..be9fe9b6351 100644 --- a/src/aks-preview/azext_aks_preview/tests/latest/test_aks_provisioning_retry.py +++ b/src/aks-preview/azext_aks_preview/tests/latest/test_aks_provisioning_retry.py @@ -330,6 +330,46 @@ def test_keyvault_validation_error_reraises_when_retries_exhausted( class TestAlreadyExistsConflictHandling(AKSRetryTestCase): + @patch.dict(os.environ, {"AZURE_CLI_TEST_OPERATION_MAX_RETRIES": "3"}) + @patch("time.sleep") + @patch("azure.cli.testsdk.base.execute") + def test_metrics_create_recovery_completes_configuration_not_just_show(self, execute, _sleep): + instance = self._make_instance() + instance.cmd = lambda command, checks: instance._cmd_with_retry(command, checks, False) + recovery = "aks update -g rg -n cluster --enable-azure-monitor-metrics --enable-control-plane-metrics" + result = self._result({"azureMonitorProfile": {"metrics": {"controlPlane": {"enabled": True}}}}) + execute.side_effect = [ + CLIError("Another operation is in progress."), + CLIError("Cluster 'cluster' already exists."), + result, + ] + self.assertIs(instance._cmd_with_retried_create_recovery( + "aks create -g rg -n cluster", recovery, + checks=[JMESPathCheck("azureMonitorProfile.metrics.controlPlane.enabled", True)], + ), result) + execute.assert_called_with(instance.cli_ctx, recovery, expect_failure=False) + self.assertIsNone(instance._retried_create_recovery_command) + + @patch.dict(os.environ, {"AZURE_CLI_TEST_OPERATION_MAX_RETRIES": "3"}) + @patch("time.sleep") + @patch("azure.cli.testsdk.base.execute") + def test_metrics_recovery_error_is_not_hidden(self, execute, _sleep): + instance = self._make_instance() + instance.cmd = lambda command, checks: instance._cmd_with_retry(command, checks or [], False) + error = CLIError("Permission denied") + execute.side_effect = [ + CLIError("Another operation is in progress."), + CLIError("Cluster 'cluster' already exists."), + error, + ] + with self.assertRaises(CLIError) as raised: + instance._cmd_with_retried_create_recovery( + "aks create -g rg -n cluster", "aks update -g rg -n cluster --enable-control-plane-metrics", + ) + self.assertIs(raised.exception, error) + self.assertEqual(execute.call_count, 3) + self.assertIsNone(instance._retried_create_recovery_command) + def test_is_resource_already_exists_conflict_detects_message(self): instance = self._make_instance() @@ -691,6 +731,78 @@ def test_ambiguous_status_error_raises_with_expect_failure( class TestLiveScenarioRegressions(AKSRetryTestCase): + def test_backup_cleanup_keeps_scope_and_reversible_soft_delete_api(self): + instance = self._make_instance() + instance.kwargs.update({"backup_rg": "owned-rg", "vault_name": "owned-vault"}) + instance.cmd = MagicMock(return_value=self._result([])) + instance._cleanup_backup("owned-vault") + first = instance.cmd.call_args_list[0] + self.assertIn("resource update --resource-group {backup_rg} --name {vault_name}", first.args[0]) + self.assertIn("--resource-type Microsoft.DataProtection/backupVaults --api-version 2025-07-01", first.args[0]) + self.assertIn("properties.securitySettings.softDeleteSettings.state=Off", first.args[0]) + self.assertEqual(len(first.kwargs["checks"]), 2) + + def test_control_plane_metrics_create_uses_workspace_and_explicit_recovery(self): + instance = self._make_instance() + instance.create_random_name = MagicMock(return_value="cluster") + instance.generate_ssh_keys = MagicMock(return_value="key") + instance._create_azure_monitor_workspace = MagicMock(return_value="/workspaces/dedicated") + instance._cmd_with_retried_create_recovery = MagicMock(side_effect=RuntimeError("stop at create")) + scenario = inspect.unwrap(instance.test_aks_create_with_control_plane_metrics) + with self.assertRaisesRegex(RuntimeError, "stop at create"): + scenario(instance, "rg", "westcentralus") + self.assertEqual(instance.kwargs["amw_id"], "/workspaces/dedicated") + call = instance._cmd_with_retried_create_recovery.call_args + for command in (call.args[0], call.kwargs["recovery_command"]): + self.assertIn("--azure-monitor-workspace-resource-id={amw_id}", command) + self.assertIn("--enable-azure-monitor-metrics", command) + self.assertIn("--enable-control-plane-metrics", command) + + def test_kms_secret_reencryption_checks_remote_exit_status(self): + instance = self._make_instance() + instance.is_live = True + for exit_code in (0, 1, None): + with self.subTest(exit_code=exit_code): + result = self._result({"provisioningState": "Succeeded", "exitCode": exit_code}) + instance.cmd = MagicMock( + side_effect=lambda command, checks: result.assert_with_checks(checks) + ) + if exit_code == 0: + instance._reencrypt_kms_secrets() + else: + with self.assertRaises(AssertionError): + instance._reencrypt_kms_secrets() + command = instance.cmd.call_args.args[0] + self.assertIn("bash -o pipefail", command) + self.assertIn("kubectl get secrets --all-namespaces -o json | kubectl replace -f -", command) + instance.is_live = False + instance.cmd.reset_mock() + instance._reencrypt_kms_secrets() + instance.cmd.assert_not_called() + + def test_both_private_kms_rotation_cases_reencrypt_before_updating_key(self): + for name in ( + "test_aks_create_with_azurekeyvaultkms_private_key_vault", + "test_aks_create_with_azurekeyvaultkms_private_cluster_v1_private_key_vault", + ): + with self.subTest(name=name): + instance = self._make_instance() + instance.create_random_name = MagicMock(return_value="name") + instance.generate_ssh_keys = MagicMock(return_value="key") + instance._get_user_assigned_identity = MagicMock(return_value="/identity") + instance._get_principal_id_of_user_assigned_identity = MagicMock(return_value="principal") + instance._get_test_identity_object_id = MagicMock(return_value="test-principal") + instance.cmd = MagicMock(return_value=self._result({ + "id": "/vault", "key": {"kid": "key-version"}, + })) + instance._reencrypt_kms_secrets = MagicMock(side_effect=RuntimeError("before rotation")) + scenario = inspect.unwrap(getattr(instance, name)) + with self.assertRaisesRegex(RuntimeError, "before rotation"): + scenario(instance, "rg", "westcentralus") + instance._reencrypt_kms_secrets.assert_called_once() + self.assertTrue(any(call.args[0].startswith("aks create ") for call in instance.cmd.call_args_list)) + self.assertFalse(any(call.args[0].startswith("aks update ") for call in instance.cmd.call_args_list)) + def test_proxy_readiness_requires_success_marker_not_just_run_command_status(self): instance = self._make_instance() for message, ready in [ @@ -963,5 +1075,67 @@ def test_refetches_agentpool_with_native_show(self, mock_execute): ) +class TestPrivateDnsRoleAssignmentRetry(AKSRetryTestCase): + ZONE = "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Network/privateDnsZones/example" + COMMAND = f"aks create -g rg -n cluster --private-dns-zone={ZONE}" + ERROR = ( + f"(ResourceMissingPermissionError) Permission to resource {ZONE}. " + "Check access result not allowed for action Microsoft.Network/privateDnsZones/read." + ) + + @patch.dict(os.environ, {"AZURE_CLI_TEST_OPERATION_MAX_RETRIES": "2"}) + @patch("time.sleep") + @patch("azure.cli.testsdk.base.execute") + def test_retries_just_assigned_zone_and_preserves_bounded_failure(self, execute, sleep): + instance = self._make_instance() + instance._private_dns_role_assignment_scope = self.ZONE + error = CLIError(self.ERROR) + expected = self._result({"provisioningState": "Succeeded"}) + execute.side_effect = [error, expected] + self.assertIs(instance._execute_with_transient_conflict_retry(self.COMMAND, False), expected) + sleep.assert_called_once() + execute.reset_mock(side_effect=True) + execute.side_effect = error + with self.assertRaises(CLIError) as raised: + instance._execute_with_transient_conflict_retry(self.COMMAND, False) + self.assertIs(raised.exception, error) + self.assertEqual(execute.call_count, 2) + + def test_unrelated_permissions_commands_and_scopes_are_not_retryable(self): + for scope, command, message in ( + (None, self.COMMAND, self.ERROR), + (self.ZONE + "-other", self.COMMAND, self.ERROR), + (self.ZONE, self.COMMAND + "-other", self.ERROR), + (self.ZONE, self.COMMAND.replace("aks create", "aks update"), self.ERROR), + (self.ZONE, self.COMMAND, self.ERROR.replace("example.", "example-other.")), + (self.ZONE, self.COMMAND, self.ERROR.replace("privateDnsZones/read", "privateDnsZones/write")), + (self.ZONE, self.COMMAND, self.ERROR.replace("ResourceMissingPermissionError", "AuthorizationFailed")), + ): + with self.subTest(scope=scope, command=command, message=message): + instance = self._make_instance() + instance._private_dns_role_assignment_scope = scope + self.assertFalse(instance._is_private_dns_role_assignment_pending(command, CLIError(message))) + + def test_scope_is_case_insensitive_and_restored_after_failure(self): + instance = self._make_instance() + instance._private_dns_role_assignment_scope = self.ZONE.upper() + self.assertTrue(instance._is_private_dns_role_assignment_pending(self.COMMAND, CLIError(self.ERROR))) + instance.cmd = MagicMock(side_effect=CLIError("failed")) + with self.assertRaises(CLIError): + instance._cmd_with_private_dns_role_assignment_retry(self.COMMAND, self.ZONE) + self.assertEqual(instance._private_dns_role_assignment_scope, self.ZONE.upper()) + + @patch("azure.cli.testsdk.base.execute") + @patch("time.sleep") + def test_expected_failure_is_not_retried(self, sleep, execute): + instance = self._make_instance() + instance._private_dns_role_assignment_scope = self.ZONE + execute.side_effect = CLIError(self.ERROR) + with self.assertRaises(CLIError): + instance._execute_with_transient_conflict_retry(self.COMMAND, True) + execute.assert_called_once() + sleep.assert_not_called() + + if __name__ == "__main__": unittest.main()