diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_help.py b/src/azure-cli/azure/cli/command_modules/appservice/_help.py index e0dad92e98c..d963d3fcb5e 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -1776,6 +1776,8 @@ examples: - name: Create a Managed Certificate for cname.mycustomdomain.com. text: az webapp config ssl create --resource-group MyResourceGroup --name MyWebapp --hostname cname.mycustomdomain.com + - name: Create a Managed Certificate and wait for it to complete (up to 10 minutes). + text: az webapp config ssl create --resource-group MyResourceGroup --name MyWebapp --hostname cname.mycustomdomain.com --wait """ helps['webapp config storage-account'] = """ diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_params.py b/src/azure-cli/azure/cli/command_modules/appservice/_params.py index bd4dc0b7ea5..9f0354f75b5 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -521,6 +521,9 @@ def load_arguments(self, _): c.argument('hostname', help='The custom domain name') c.argument('name', options_list=['--name', '-n'], help='Name of the web app.') c.argument('resource-group', options_list=['--resource-group', '-g'], help='Name of resource group.') + c.argument('wait', options_list=['--wait'], action='store_true', default=False, + help='Wait up to 10 minutes for the certificate to be created. ' + 'Returns an error if creation times out instead of silently returning.') with self.argument_context(scope + ' config hostname') as c: c.argument('hostname', completer=get_hostname_completion_list, help="hostname assigned to the site, such as custom domains", id_part='child_name_1') diff --git a/src/azure-cli/azure/cli/command_modules/appservice/custom.py b/src/azure-cli/azure/cli/command_modules/appservice/custom.py index 386ba088608..854dc69648d 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -5960,7 +5960,7 @@ def import_ssl_cert(cmd, resource_group_name, key_vault, key_vault_certificate_n certificate_envelope=kv_cert_def) -def create_managed_ssl_cert(cmd, resource_group_name, name, hostname, slot=None, certificate_name=None): +def create_managed_ssl_cert(cmd, resource_group_name, name, hostname, slot=None, certificate_name=None, wait=False): Certificate = cmd.get_models('Certificate') hostname = hostname.lower() client = web_client_factory(cmd.cli_ctx) @@ -5997,7 +5997,8 @@ def create_managed_ssl_cert(cmd, resource_group_name, name, hostname, slot=None, poll_url = ex.response.headers['Location'] if 'Location' in ex.response.headers else None if ex.response.status_code == 202 and poll_url: r = send_raw_request(cmd.cli_ctx, method='get', url=poll_url) - poll_timeout = time.time() + 60 * 2 # 2 minute timeout + poll_timeout_minutes = 10 if wait else 2 + poll_timeout = time.time() + 60 * poll_timeout_minutes while r.status_code != 200 and time.time() < poll_timeout: time.sleep(5) @@ -6008,6 +6009,11 @@ def create_managed_ssl_cert(cmd, resource_group_name, name, hostname, slot=None, return r.json() except ValueError: return r.text + if wait: + raise CLIError("Managed Certificate creation for '{}' timed out after {} minutes. " + "Check status with 'az webapp config ssl show -g {} " + "--certificate-name {}'.".format(hostname, poll_timeout_minutes, + resource_group_name, certificate_name)) logger.warning("Managed Certificate creation in progress. Please use the command " "'az webapp config ssl show -g %s --certificate-name %s' " " to view your certificate once it is created", resource_group_name, certificate_name) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py index 853eadc1edd..b5269bf6b3f 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py @@ -469,6 +469,94 @@ def test_create_managed_ssl_cert(self, generic_site_op_mock, client_factory_mock certificate_envelope=cert_def) + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.custom._verify_hostname_binding', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation', autospec=True) + def test_create_managed_ssl_cert_wait_timeout_raises_error(self, generic_site_op_mock, client_factory_mock, + verify_binding_mock, send_raw_request_mock): + """Test that --wait raises CLIError on timeout instead of returning None.""" + webapp_name = 'someWebAppName' + rg_name = 'someRgName' + farm_id = '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg1/providers/Microsoft.Web/serverfarms/farm1' + host_name = 'www.contoso.com' + + client = mock.MagicMock() + client_factory_mock.return_value = client + cmd_mock = _get_test_cmd() + cli_ctx_mock = mock.MagicMock() + cli_ctx_mock.data = {'subscription_id': 'sub1'} + cmd_mock.cli_ctx = cli_ctx_mock + Site, Certificate = cmd_mock.get_models('Site', 'Certificate') + site = Site(name=webapp_name, location='westeurope') + site.server_farm_id = farm_id + generic_site_op_mock.return_value = site + verify_binding_mock.return_value = True + + # Simulate 202 with Location header + ex_response = mock.MagicMock() + ex_response.status_code = 202 + ex_response.headers = {'Location': 'https://polling-url'} + api_exception = Exception('accepted') + api_exception.response = ex_response + client.certificates.create_or_update.side_effect = api_exception + + # Polling always returns 202 (never completes) + poll_response = mock.MagicMock() + poll_response.status_code = 202 + send_raw_request_mock.return_value = poll_response + + # With wait=True and mocked time to simulate immediate timeout + with mock.patch('azure.cli.command_modules.appservice.custom.time') as time_mock: + time_mock.time.side_effect = [0, 999999] # Start, then past timeout + time_mock.sleep = mock.MagicMock() + with self.assertRaises(CLIError): + create_managed_ssl_cert(cmd_mock, rg_name, webapp_name, host_name, None, wait=True) + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.custom._verify_hostname_binding', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation', autospec=True) + def test_create_managed_ssl_cert_no_wait_returns_none(self, generic_site_op_mock, client_factory_mock, + verify_binding_mock, send_raw_request_mock): + """Test that without --wait, timeout returns None with a warning (default behavior).""" + webapp_name = 'someWebAppName' + rg_name = 'someRgName' + farm_id = '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg1/providers/Microsoft.Web/serverfarms/farm1' + host_name = 'www.contoso.com' + + client = mock.MagicMock() + client_factory_mock.return_value = client + cmd_mock = _get_test_cmd() + cli_ctx_mock = mock.MagicMock() + cli_ctx_mock.data = {'subscription_id': 'sub1'} + cmd_mock.cli_ctx = cli_ctx_mock + Site, Certificate = cmd_mock.get_models('Site', 'Certificate') + site = Site(name=webapp_name, location='westeurope') + site.server_farm_id = farm_id + generic_site_op_mock.return_value = site + verify_binding_mock.return_value = True + + # Simulate 202 with Location header + ex_response = mock.MagicMock() + ex_response.status_code = 202 + ex_response.headers = {'Location': 'https://polling-url'} + api_exception = Exception('accepted') + api_exception.response = ex_response + client.certificates.create_or_update.side_effect = api_exception + + # Polling always returns 202 + poll_response = mock.MagicMock() + poll_response.status_code = 202 + send_raw_request_mock.return_value = poll_response + + # Without wait (default), should return None, not raise + with mock.patch('azure.cli.command_modules.appservice.custom.time') as time_mock: + time_mock.time.side_effect = [0, 999999] + time_mock.sleep = mock.MagicMock() + result = create_managed_ssl_cert(cmd_mock, rg_name, webapp_name, host_name, None, wait=False) + self.assertIsNone(result) + def test_update_app_settings_error_handling_no_parameters(self): """Test that MutuallyExclusiveArgumentError is raised when neither settings nor slot_settings are provided.""" cmd_mock = _get_test_cmd()