diff --git a/src/azure-cli/azure/cli/command_modules/mysql/_client_factory.py b/src/azure-cli/azure/cli/command_modules/mysql/_client_factory.py index 4e8ee4f7573..245453774b3 100644 --- a/src/azure-cli/azure/cli/command_modules/mysql/_client_factory.py +++ b/src/azure-cli/azure/cli/command_modules/mysql/_client_factory.py @@ -15,6 +15,8 @@ # Pin the flexible server management API version. The SDK default advanced to a newer # preview; keep the CLI on the version the existing commands were built and recorded against. MYSQL_FLEXIBLE_API_VERSION = '2025-06-01-preview' +# Rename uses the ARM-registered stable API without changing existing commands. +MYSQL_FLEXIBLE_RENAME_API_VERSION = '2025-12-30' # Fabric mirroring operations are only available from this newer API version. MYSQL_FLEXIBLE_FABRIC_MIRRORING_API_VERSION = '2025-12-01-preview' @@ -99,6 +101,11 @@ def cf_mysql_flexible_servers(cli_ctx, _): return get_mysql_flexible_management_client(cli_ctx).servers +def cf_mysql_flexible_servers_rename(cli_ctx, _): + return get_mysql_flexible_management_client( + cli_ctx, api_version=MYSQL_FLEXIBLE_RENAME_API_VERSION).servers + + def cf_mysql_flexible_firewall_rules(cli_ctx, _): return get_mysql_flexible_management_client(cli_ctx).firewall_rules diff --git a/src/azure-cli/azure/cli/command_modules/mysql/_help.py b/src/azure-cli/azure/cli/command_modules/mysql/_help.py index 09e8ec3742a..590f8b79d58 100644 --- a/src/azure-cli/azure/cli/command_modules/mysql/_help.py +++ b/src/azure-cli/azure/cli/command_modules/mysql/_help.py @@ -378,6 +378,33 @@ text: az mysql flexible-server delete --resource-group testGroup --name testserver --yes """ +helps['mysql flexible-server rename'] = """ +type: command +short-summary: Submit a request to rename an existing MySQL flexible server. +long-summary: > + This preview operation requires rename support to be enabled by the service. It renames the existing server + within the same subscription, resource group, and location; it does not create a replacement server. + The server's resource ID and fully qualified domain name change. Update connection strings and references + to the old resource ID, including infrastructure-as-code state such as Terraform state, after completion. + Do not assume the old endpoint remains available or that the operation has no downtime. + For a primary or replica, verify replication and dependent resource references after the rename. + Availability and restrictions for replication topologies, high availability, and networking are service-controlled. + Return immediately after submission without polling for completion. Output contains statusCode (the initial + HTTP status), content (the initial response body as text), and azureAsyncOperation (the Azure-AsyncOperation + response header, or null if absent). The same output is returned with --no-wait. + Acceptance does not mean the rename has completed. Poll the returned URL using az rest --method get --url URL + until status is Succeeded, Failed, or Canceled. After Succeeded, verify the destination server and replicas. + If the service omits the operation URL, inspect the destination server before considering the rename complete. + If the command is interrupted, check the operation and destination server before retrying. +examples: + - name: Submit a rename and return the initial HTTP response and operation URL. + text: az mysql flexible-server rename --resource-group testGroup --name oldserver --new-name newserver + - name: Submit a rename without prompting for confirmation, using the optional compatibility flag. + text: az mysql flexible-server rename --resource-group testGroup --name oldserver --new-name newserver --no-wait --yes + - name: Rename a server identified by its resource ID. + text: az mysql flexible-server rename --ids /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/testGroup/providers/Microsoft.DBforMySQL/flexibleServers/oldserver --new-name newserver +""" + helps['mysql flexible-server restore'] = """ type: command short-summary: Restore a flexible server from backup. diff --git a/src/azure-cli/azure/cli/command_modules/mysql/_params.py b/src/azure-cli/azure/cli/command_modules/mysql/_params.py index 1346ee93374..4e2960b2f9c 100644 --- a/src/azure-cli/azure/cli/command_modules/mysql/_params.py +++ b/src/azure-cli/azure/cli/command_modules/mysql/_params.py @@ -11,7 +11,7 @@ from azure.cli.command_modules.mysql.action import AddArgs from azure.cli.command_modules.mysql.random.generate import generate_username from azure.cli.command_modules.mysql._validators import public_access_validator, maintenance_window_validator, ip_address_validator, \ - firewall_rule_name_validator, validate_identity, validate_byok_identity, validate_identities, validate_action_name, validate_branch + firewall_rule_name_validator, validate_identity, validate_byok_identity, validate_identities, validate_action_name, validate_branch, validate_server_rename from azure.cli.core.local_context import LocalContextAttribute, LocalContextAction from ._util import get_current_time, normalize_mysql_tier from argcomplete.completers import FilesCompleter @@ -426,6 +426,12 @@ def load_arguments(self, _): # pylint: disable=too-many-statements, too-many- with self.argument_context('mysql flexible-server delete') as c: c.argument('yes', arg_type=yes_arg_type) + with self.argument_context('mysql flexible-server rename') as c: + c.argument('new_name', options_list=['--new-name'], required=True, validator=validate_server_rename, + help='New name for the existing server. The subscription, resource group, and location do not change.') + c.extra('no_wait', action='store_true', + help='Accepted for compatibility. Rename always returns the initial response without waiting for completion.') + with self.argument_context('mysql flexible-server restore') as c: c.argument('restore_point_in_time', arg_type=restore_point_in_time_arg_type) c.argument('source_server', arg_type=source_server_arg_type) diff --git a/src/azure-cli/azure/cli/command_modules/mysql/_validators.py b/src/azure-cli/azure/cli/command_modules/mysql/_validators.py index ca4c599aa55..38dde1c32e7 100644 --- a/src/azure-cli/azure/cli/command_modules/mysql/_validators.py +++ b/src/azure-cli/azure/cli/command_modules/mysql/_validators.py @@ -421,6 +421,18 @@ def firewall_rule_name_validator(ns): "and no more than 80 characters in length. ") +def validate_server_rename(namespace): + ids = getattr(namespace, '_ids', None) + if ids: + if len(ids) != 1: + raise InvalidArgumentValueError('Specify exactly one MySQL flexible server resource ID to rename.') + parts = parse_resource_id(ids[0]) + if (parts.get('namespace', '').lower() != 'microsoft.dbformysql' or + parts.get('type', '').lower() != 'flexibleservers' or + parts.get('child_name_1')): + raise InvalidArgumentValueError('--ids must identify a MySQL flexible server, not another resource type.') + + def validate_server_name(db_context, server_name, type_): client = db_context.cf_availability(db_context.cmd.cli_ctx, '_') diff --git a/src/azure-cli/azure/cli/command_modules/mysql/commands.py b/src/azure-cli/azure/cli/command_modules/mysql/commands.py index 20ebe4c99ba..fcc7abcdcd4 100644 --- a/src/azure-cli/azure/cli/command_modules/mysql/commands.py +++ b/src/azure-cli/azure/cli/command_modules/mysql/commands.py @@ -6,6 +6,7 @@ from azure.cli.command_modules.mysql._client_factory import ( cf_mysql_advanced_threat_protection, cf_mysql_flexible_servers, + cf_mysql_flexible_servers_rename, cf_mysql_flexible_firewall_rules, cf_mysql_flexible_config, cf_mysql_flexible_db, @@ -131,6 +132,11 @@ def load_command_table(self, _): g.custom_wait_command('wait', 'flexible_server_mysql_get') g.custom_command('restart', 'flexible_server_restart') g.custom_command('detach-vnet', 'flexible_server_detach_vnet') + # Register --no-wait explicitly so the command invoker does not discard the initial response. + g.custom_command('rename', 'flexible_server_rename', is_preview=True, + client_factory=cf_mysql_flexible_servers_rename, + confirmation='Renaming changes the server resource ID and endpoint. ' + 'Update connection strings and resource references after completion. Continue?') with self.command_group('mysql flexible-server import', mysql_flexible_servers_sdk, custom_command_type=mysql_custom, diff --git a/src/azure-cli/azure/cli/command_modules/mysql/custom.py b/src/azure-cli/azure/cli/command_modules/mysql/custom.py index 901a00ead35..53fa9d5439a 100644 --- a/src/azure-cli/azure/cli/command_modules/mysql/custom.py +++ b/src/azure-cli/azure/cli/command_modules/mysql/custom.py @@ -72,6 +72,35 @@ def flexible_server_stop(client, resource_group_name=None, server_name=None, no_ return sdk_no_wait(no_wait, client.begin_stop, resource_group_name, server_name) +def flexible_server_rename(cmd, client, resource_group_name, server_name, new_name, no_wait=False): + if not new_name: + raise InvalidArgumentValueError('--new-name must not be empty.') + new_name = new_name.lower() + if new_name == server_name.lower(): + raise InvalidArgumentValueError('--new-name must differ from the current server name, ignoring case.') + + source_server = client.get(resource_group_name, server_name) + db_context = DbContext( + cmd=cmd, cf_availability=cf_mysql_check_resource_availability, + cf_availability_without_location=cf_mysql_check_resource_availability_without_location, + location=source_server.location) + validate_server_name(db_context, new_name, DELEGATION_SERVICE_NAME) + + parameters = models.Server( + location=source_server.location, + create_mode='Rename', + source_server_resource_id=source_server.id) + responses = [] + client.begin_create( + resource_group_name, new_name, parameters, polling=False, raw_response_hook=responses.append) + response = responses[-1].http_response + return { + 'statusCode': response.status_code, + 'content': response.text(), + 'azureAsyncOperation': response.headers.get('Azure-AsyncOperation') + } + + def flexible_server_update_set(client, resource_group_name, server_name, parameters): return client.begin_update(resource_group_name, server_name, parameters) diff --git a/src/azure-cli/azure/cli/command_modules/mysql/tests/latest/test_mysql_custom.py b/src/azure-cli/azure/cli/command_modules/mysql/tests/latest/test_mysql_custom.py index b07b52eb35a..68d006792e4 100644 --- a/src/azure-cli/azure/cli/command_modules/mysql/tests/latest/test_mysql_custom.py +++ b/src/azure-cli/azure/cli/command_modules/mysql/tests/latest/test_mysql_custom.py @@ -170,6 +170,11 @@ def argument(self, argument_name, *args, **settings): if args: settings['arg_type'] = args[0] self.registrations.append((self.command_name, argument_name, settings)) + + def extra(self, argument_name, *args, **settings): + self.argument(argument_name, *args, **settings) + + class _FakeAdvancedThreatProtectionClient: def begin_update(self, resource_group_name, server_name, advanced_threat_protection_name, parameters): diff --git a/src/azure-cli/azure/cli/command_modules/mysql/tests/latest/test_mysql_flexible_server_rename.py b/src/azure-cli/azure/cli/command_modules/mysql/tests/latest/test_mysql_flexible_server_rename.py new file mode 100644 index 00000000000..c0ca097046d --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/mysql/tests/latest/test_mysql_flexible_server_rename.py @@ -0,0 +1,400 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import io +import json +import unittest +from contextlib import redirect_stderr +from types import SimpleNamespace +from unittest.mock import MagicMock, patch +from urllib.parse import parse_qs, urlsplit + +import requests +from azure.core.credentials import AccessToken +from azure.core.exceptions import HttpResponseError, ServiceRequestError, ServiceResponseError +from azure.core.pipeline.transport import RequestsTransport +from azure.mgmt.mysqlflexibleservers import MySQLManagementClient +from azure.cli.core.azclierror import InvalidArgumentValueError, ValidationError +from azure.cli.core.mock import DummyCli +from azure.cli.command_modules.mysql import _client_factory, custom + + +class MysqlFlexibleServerRenameTest(unittest.TestCase): + subscription = '00000000-0000-0000-0000-000000000000' + endpoint = 'https://management.example.test' + source_id = ('/subscriptions/' + subscription + + '/resourceGroups/testGroup/providers/Microsoft.DBforMySQL/flexibleServers/oldserver') + destination_id = source_id.rsplit('/', 1)[0] + '/newserver' + availability_path = ('/subscriptions/' + subscription + + '/providers/Microsoft.DBforMySQL/locations/eastus/checkNameAvailability') + operation_path = ('/subscriptions/' + subscription + + '/providers/Microsoft.DBforMySQL/locations/eastus/azureAsyncOperation/rename-operation') + api_version = '2025-12-30' + availability_api_version = '2025-06-01-preview' + + def setUp(self): + self.responses = [] + self.requests = [] + credential = MagicMock(spec=['get_token']) + credential.get_token.return_value = AccessToken('test-token', 4102444800) + self.session = MagicMock() + self.session.request.side_effect = self._request + self.clients = {} + for api_version in (self.api_version, self.availability_api_version): + client = MySQLManagementClient( + credential, self.subscription, base_url=self.endpoint, api_version=api_version, + transport=RequestsTransport(session=self.session), polling_interval=0, retry_total=0) + self.clients[api_version] = client + self.addCleanup(client.close) + self.client = self.clients[self.api_version] + factory = patch.object( + _client_factory, 'get_mgmt_service_client', + side_effect=lambda cli_ctx, client_type, **kwargs: self.clients[kwargs['api_version']]) + self.factory = factory.start() + self.addCleanup(factory.stop) + environment = patch.dict('os.environ', {_client_factory.RM_URI_OVERRIDE: ''}) + environment.start() + self.addCleanup(environment.stop) + self.cmd = SimpleNamespace(cli_ctx=MagicMock()) + + def _request(self, method, url, **kwargs): + self.assertTrue(self.responses, 'Unexpected HTTP request: {} {}'.format(method, url)) + expected_method, path, status, body, headers = self.responses.pop(0) + parsed = urlsplit(url) + self.assertEqual(expected_method, method) + self.assertEqual(self.endpoint, '{}://{}'.format(parsed.scheme, parsed.netloc)) + self.assertEqual(path, parsed.path) + api_version = self.availability_api_version if path == self.availability_path else self.api_version + self.assertEqual([api_version], parse_qs(parsed.query)['api-version']) + data = kwargs.get('data') + self.requests.append((method, path, json.loads(data) if data else None)) + response = requests.Response() + response.status_code = status + response.url = url + response.headers.update({'Content-Type': 'application/json', **headers}) + response._content = b'' if body is None else json.dumps(body).encode('utf-8') + response.raw = io.BytesIO(response._content) + response.request = requests.Request(method, url).prepare() + return response + + def _queue(self, method, path, status=200, body=None, headers=None): + self.responses.append((method, path, status, body, headers or {})) + + def _source(self): + self._queue('GET', self.source_id, body={ + 'id': self.source_id, 'name': 'oldserver', 'location': 'eastus', + 'properties': {'state': 'Ready'}, + 'sku': {'name': 'Standard_D2ds_v4', 'tier': 'GeneralPurpose'}, + 'tags': {'preserve': 'do-not-send-in-rename'}}) + + def _available(self): + self._queue('POST', self.availability_path, body={'nameAvailable': True}) + + def _accepted(self): + self._source() + self._available() + self._queue('PUT', self.destination_id, 202, headers={ + 'Azure-AsyncOperation': self.endpoint + self.operation_path + '?api-version=' + self.api_version, + 'Retry-After': '0'}) + + def _destination(self): + return { + 'id': self.destination_id, 'name': 'newserver', 'location': 'eastus', + 'properties': {'state': 'Ready', 'fullyQualifiedDomainName': 'newserver.mysql.example.test'}} + + def _rename(self, new_name='newserver', no_wait=False): + return custom.flexible_server_rename( + self.cmd, self.client.servers, 'testGroup', 'oldserver', new_name, no_wait=no_wait) + + def _invoke(self, arguments): + cli = DummyCli() + stdout, stderr = io.StringIO(), io.StringIO() + with redirect_stderr(stderr): + try: + exit_code = cli.invoke( + ['mysql', 'flexible-server', 'rename'] + arguments, out_file=stdout) + except SystemExit as ex: + exit_code = ex.code + return exit_code, stdout.getvalue(), stderr.getvalue() + + def _assert_payload(self): + puts = [entry for entry in self.requests if entry[0] == 'PUT'] + self.assertEqual([('PUT', self.destination_id, { + 'location': 'eastus', + 'properties': {'createMode': 'Rename', 'sourceServerResourceId': self.source_id} + })], puts) + + def test_mysql_rename_returns_initial_response_without_polling(self): + self._accepted() + result = self._rename(new_name='NewServer') + self.assertEqual({ + 'statusCode': 202, + 'content': '', + 'azureAsyncOperation': self.endpoint + self.operation_path + '?api-version=' + self.api_version + }, result) + self._assert_payload() + self.assertEqual(['GET', 'POST', 'PUT'], [entry[0] for entry in self.requests]) + self.assertFalse(self.responses) + + def test_mysql_rename_no_wait_submits_once_without_polling(self): + self._accepted() + result = self._rename(no_wait=True) + self.assertEqual(202, result['statusCode']) + self.assertEqual('', result['content']) + self.assertEqual(self.endpoint + self.operation_path + '?api-version=' + self.api_version, + result['azureAsyncOperation']) + self._assert_payload() + self.assertEqual(['GET', 'POST', 'PUT'], [entry[0] for entry in self.requests]) + self.assertFalse(self.responses) + + def test_mysql_rename_preserves_initial_body_and_case_insensitive_header(self): + body = {**self._destination(), 'operationDetails': {'status': 'InProgress'}} + operation_url = self.endpoint + self.operation_path + '?api-version=' + self.api_version + for status in (200, 201, 202): + with self.subTest(status=status): + self.requests.clear() + self._source() + self._available() + self._queue('PUT', self.destination_id, status, body, + {'azure-asyncoperation': operation_url}) + result = self._rename() + self.assertEqual({ + 'statusCode': status, 'content': json.dumps(body), 'azureAsyncOperation': operation_url + }, result) + self._assert_payload() + self.assertEqual(['GET', 'POST', 'PUT'], [entry[0] for entry in self.requests]) + self.assertFalse(self.responses) + + def test_mysql_rename_missing_operation_header_is_not_fabricated(self): + for status in (200, 201, 202): + with self.subTest(status=status): + self.requests.clear() + self._source() + self._available() + self._queue('PUT', self.destination_id, status) + self.assertEqual({'statusCode': status, 'content': '', 'azureAsyncOperation': None}, + self._rename()) + self.assertEqual(['GET', 'POST', 'PUT'], [entry[0] for entry in self.requests]) + self.assertFalse(self.responses) + + def test_mysql_rename_same_name_and_empty_name_are_rejected(self): + for name in ('oldserver', 'OldServer', ''): + with self.subTest(name=name), self.assertRaises(InvalidArgumentValueError): + self._rename(new_name=name) + self.assertFalse(self.requests) + + def test_mysql_rename_invalid_name_length_is_rejected(self): + for name in ('ab', 'x' * 64): + with self.subTest(name=name): + self._source() + with self.assertRaises(ValidationError): + self._rename(new_name=name) + self.assertTrue(all(entry[0] == 'GET' for entry in self.requests)) + + def test_mysql_rename_name_validation_uses_source_location(self): + self._source() + self._queue('POST', self.availability_path, body={ + 'nameAvailable': False, 'reason': 'Invalid', 'message': 'Invalid server name.'}) + with self.assertRaisesRegex(ValidationError, 'Invalid server name'): + self._rename(new_name='invalid_name') + self.assertEqual({'name': 'invalid_name', 'type': 'Microsoft.DBforMySQL/flexibleServers'}, + self.requests[-1][2]) + self.assertFalse(self.responses) + + def test_mysql_rename_unavailable_name_is_rejected(self): + self._source() + self._queue('POST', self.availability_path, body={ + 'nameAvailable': False, 'reason': 'AlreadyExists', 'message': 'Target name is unavailable.'}) + with self.assertRaisesRegex(ValidationError, 'Target name is unavailable'): + self._rename() + self.assertFalse(any(entry[0] == 'PUT' for entry in self.requests)) + + def test_mysql_rename_source_read_errors_never_submit(self): + for status in (401, 403, 404): + with self.subTest(status=status): + self._queue('GET', self.source_id, status, {'error': { + 'code': 'SourceReadFailed', 'message': 'Cannot read source.'}}) + with self.assertRaisesRegex(HttpResponseError, 'Cannot read source'): + self._rename() + self.assertTrue(all(entry[0] == 'GET' for entry in self.requests)) + + def test_mysql_rename_service_rejection_is_preserved(self): + for status, code in ((409, 'Conflict'), (400, 'FeatureNotEnabled'), + (400, 'InvalidApiVersionParameter'), (403, 'AuthorizationFailed')): + with self.subTest(code=code): + self._source() + self._available() + self._queue('PUT', self.destination_id, status, {'error': {'code': code, 'message': code}}) + with self.assertRaisesRegex(HttpResponseError, code): + self._rename() + self.assertEqual(4, len([entry for entry in self.requests if entry[0] == 'PUT'])) + + def test_mysql_rename_submission_transport_failure_is_not_success(self): + self._accepted() + original_request = self.session.request.side_effect + + def request(method, url, **kwargs): + response = original_request(method, url, **kwargs) + if method == 'PUT': + raise requests.exceptions.ConnectionError('Rename submission connection failed') + return response + + self.session.request.side_effect = request + with self.assertRaises(ServiceRequestError): + self._rename() + self._assert_payload() + self.assertFalse(self.responses) + + def test_mysql_rename_submission_timeout_is_not_success(self): + self._accepted() + original_request = self.session.request.side_effect + + def request(method, url, **kwargs): + response = original_request(method, url, **kwargs) + if method == 'PUT': + raise requests.exceptions.ReadTimeout('Rename submission timed out') + return response + + self.session.request.side_effect = request + with self.assertRaises(ServiceResponseError): + self._rename() + self._assert_payload() + self.assertFalse(self.responses) + + def test_mysql_rename_uses_canonical_source_id(self): + canonical_id = self.source_id.replace('testGroup', 'TESTGROUP') + self._queue('GET', self.source_id, body={ + 'id': canonical_id, 'name': 'oldserver', 'location': 'eastus'}) + self._available() + self._queue('PUT', self.destination_id, body=self._destination()) + self._rename() + self.assertEqual(canonical_id, self.requests[-1][2]['properties']['sourceServerResourceId']) + + def test_mysql_rename_cli_default_and_no_wait_return_initial_response(self): + for no_wait in (False, True): + with self.subTest(no_wait=no_wait): + self.requests.clear() + self._accepted() + args = ['-g', 'testGroup', '-n', 'oldserver', '--new-name', 'newserver', '--yes'] + if no_wait: + args.append('--no-wait') + code, stdout, stderr = self._invoke(args) + self.assertEqual(0, code, stderr) + self.assertEqual({ + 'statusCode': 202, + 'content': '', + 'azureAsyncOperation': self.endpoint + self.operation_path + '?api-version=' + self.api_version + }, json.loads(stdout)) + self._assert_payload() + self.assertEqual(['GET', 'POST', 'PUT'], [entry[0] for entry in self.requests]) + self.assertFalse(self.responses) + + def test_mysql_rename_cli_ids(self): + self._accepted() + code, stdout, stderr = self._invoke( + ['--ids', self.source_id, '--new-name', 'newserver', '--yes']) + self.assertEqual(0, code, stderr) + self.assertEqual(202, json.loads(stdout)['statusCode']) + self.assertEqual(self.endpoint + self.operation_path + '?api-version=' + self.api_version, + json.loads(stdout)['azureAsyncOperation']) + self._assert_payload() + self.assertFalse(self.responses) + self.assertEqual(self.subscription, self.factory.call_args.args[0].data['subscription_id']) + + def test_mysql_rename_cli_operation_url_can_be_queried(self): + self._accepted() + code, stdout, stderr = self._invoke([ + '-g', 'testGroup', '-n', 'oldserver', '--new-name', 'newserver', '--no-wait', '--yes', + '--query', 'azureAsyncOperation', '--output', 'tsv']) + self.assertEqual(0, code, stderr) + self.assertEqual(self.endpoint + self.operation_path + '?api-version=' + self.api_version, stdout.strip()) + self.assertEqual(['GET', 'POST', 'PUT'], [entry[0] for entry in self.requests]) + self.assertFalse(self.responses) + + def test_mysql_rename_cli_rejects_wrong_resource_ids(self): + for source_id in ( + '/invalid', + self.source_id.replace('Microsoft.DBforMySQL', 'Microsoft.DBforPostgreSQL'), + self.source_id.replace('flexibleServers', 'servers'), + self.source_id + '/databases/mydb'): + with self.subTest(source_id=source_id): + code, _, _ = self._invoke(['--ids', source_id, '--new-name', 'newserver', '--yes']) + self.assertNotEqual(0, code) + self.assertFalse(self.requests) + self.factory.assert_not_called() + + def test_mysql_rename_cli_requires_source_and_destination(self): + for args in (['--new-name', 'newserver'], ['-g', 'testGroup', '-n', 'oldserver'], + ['-g', 'testGroup', '--new-name', 'newserver']): + with self.subTest(args=args): + code, _, _ = self._invoke(args + ['--yes']) + self.assertNotEqual(0, code) + self.assertFalse(self.requests) + + def test_mysql_rename_cli_rejects_multiple_sources_and_destination_id(self): + code, _, _ = self._invoke( + ['--ids', self.source_id, self.source_id.replace('oldserver', 'anotherserver'), + '--new-name', 'newserver', '--yes']) + self.assertNotEqual(0, code) + self.factory.assert_not_called() + + self._source() + code, _, _ = self._invoke( + ['--ids', self.source_id, '--new-name', + self.destination_id.replace('testGroup', 'anotherGroup'), '--yes']) + self.assertNotEqual(0, code) + self.assertFalse(any(entry[0] == 'PUT' for entry in self.requests)) + + def test_mysql_rename_cli_confirmation_can_be_declined(self): + with patch('azure.cli.core.commands.AzCliCommand._user_confirmed', return_value=False): + code, _, _ = self._invoke( + ['-g', 'testGroup', '-n', 'oldserver', '--new-name', 'newserver']) + self.assertNotEqual(0, code) + self.assertFalse(self.requests) + + def test_mysql_rename_cli_submission_failure_has_nonzero_exit(self): + self._source() + self._available() + self._queue('PUT', self.destination_id, 409, body={ + 'error': {'code': 'RenameFailed', 'message': 'Rename failed.'}}) + with self.assertLogs(level='ERROR') as logs: + code, stdout, _ = self._invoke( + ['-g', 'testGroup', '-n', 'oldserver', '--new-name', 'newserver', '--yes']) + self.assertNotEqual(0, code) + self.assertEqual('', stdout.strip()) + self.assertIn('RenameFailed', '\n'.join(logs.output)) + self.assertFalse(self.responses) + + def test_mysql_rename_factory_preserves_cloud_and_api_version(self): + servers = _client_factory.cf_mysql_flexible_servers_rename(self.cmd.cli_ctx, None) + self.assertIs(self.client.servers, servers) + self.factory.assert_called_once_with( + self.cmd.cli_ctx, MySQLManagementClient, api_version=self.api_version) + + def test_mysql_rename_factory_preserves_endpoint_and_subscription_overrides(self): + subscription = '11111111-1111-1111-1111-111111111111' + with patch.dict('os.environ', { + _client_factory.RM_URI_OVERRIDE: self.endpoint, + _client_factory.SUB_ID_OVERRIDE: subscription, + _client_factory.AZURE_CLIENT_ID: ''}), \ + patch('azure.mgmt.mysqlflexibleservers.MySQLManagementClient', + return_value=self.client) as client_type: + servers = _client_factory.cf_mysql_flexible_servers_rename(self.cmd.cli_ctx, None) + self.assertIs(self.client.servers, servers) + client_type.assert_called_once() + self.assertEqual(subscription, client_type.call_args.kwargs['subscription_id']) + self.assertEqual(self.endpoint, client_type.call_args.kwargs['base_url']) + self.assertEqual(self.api_version, client_type.call_args.kwargs['api_version']) + self.factory.assert_not_called() + + def test_mysql_rename_does_not_change_default_server_api_version(self): + servers = _client_factory.cf_mysql_flexible_servers(self.cmd.cli_ctx, None) + self.assertIs(self.clients[self.availability_api_version].servers, servers) + self.factory.assert_called_once_with( + self.cmd.cli_ctx, MySQLManagementClient, api_version=self.availability_api_version) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/azure-cli/azure/cli/command_modules/mysql/tests/latest/test_mysql_flexible_server_rename_live.py b/src/azure-cli/azure/cli/command_modules/mysql/tests/latest/test_mysql_flexible_server_rename_live.py new file mode 100644 index 00000000000..060001c020c --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/mysql/tests/latest/test_mysql_flexible_server_rename_live.py @@ -0,0 +1,110 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import os +import time +import unittest + +from azure.cli.testsdk import LiveScenarioTest, ResourceGroupPreparer + + +@unittest.skipUnless(os.environ.get('AZURE_MYSQL_RENAME_TEST_LOCATION'), + 'Set AZURE_MYSQL_RENAME_TEST_LOCATION to an approved, rename-enabled test region.') +class MysqlFlexibleServerRenameLiveTest(LiveScenarioTest): + """Run explicitly with azdev test --live in an approved subscription. + + These tests create billable servers in disposable resource groups. Cross-region coverage also requires + AZURE_MYSQL_RENAME_TEST_REPLICA_LOCATION. They verify ARM identity and replica references, not data-plane continuity. + """ + + @ResourceGroupPreparer() + def test_mysql_flexible_server_rename_standalone_live(self, resource_group): + source = self._create_server(resource_group) + renamed = self._rename(source) + renamed = self._rename(renamed, no_wait=True) + self.cmd('mysql flexible-server delete --ids {} --yes'.format(renamed['id'])) + + @ResourceGroupPreparer() + def test_mysql_flexible_server_rename_ha_live(self, resource_group): + source = self._create_server(resource_group, high_availability=True) + renamed = self._rename(source) + self.assertEqual(source['highAvailability']['mode'], renamed['highAvailability']['mode']) + + @ResourceGroupPreparer() + def test_mysql_flexible_server_rename_replication_live(self, resource_group): + self._test_replication(resource_group, os.environ['AZURE_MYSQL_RENAME_TEST_LOCATION']) + + @unittest.skipUnless(os.environ.get('AZURE_MYSQL_RENAME_TEST_REPLICA_LOCATION'), + 'Set AZURE_MYSQL_RENAME_TEST_REPLICA_LOCATION to an approved second region.') + @ResourceGroupPreparer() + def test_mysql_flexible_server_rename_cross_region_live(self, resource_group): + location = os.environ['AZURE_MYSQL_RENAME_TEST_REPLICA_LOCATION'] + self.assertNotEqual(os.environ['AZURE_MYSQL_RENAME_TEST_LOCATION'], location) + self._test_replication(resource_group, location) + + def _create_server(self, resource_group, high_availability=False): + self.kwargs.update( + rg=resource_group, location=os.environ['AZURE_MYSQL_RENAME_TEST_LOCATION'], + name=self.create_random_name('clirename', 20)) + self.cmd('mysql flexible-server create -g {rg} -n {name} -l {location} ' + '--tier GeneralPurpose --sku-name Standard_D2ds_v4 --storage-size 32 --public-access none --yes' + + (' --high-availability SameZone' if high_availability else '')) + return self.cmd('mysql flexible-server show -g {rg} -n {name}').get_output_in_json() + + def _rename(self, source, no_wait=False): + self.kwargs.update( + old_name=source['name'], source_id=source['id'], + new_name=self.create_random_name('clirenamed', 20)) + if no_wait: + result = self.cmd( + 'mysql flexible-server rename --ids {source_id} --new-name {new_name} --no-wait --yes' + ).get_output_in_json() + else: + result = self.cmd( + 'mysql flexible-server rename -g {rg} -n {old_name} --new-name {new_name} --yes').get_output_in_json() + self.assertIn(result['statusCode'], (200, 201, 202)) + self.assertIsInstance(result['content'], str) + if result['statusCode'] in (201, 202): + self.assertTrue(result['azureAsyncOperation'], result) + if result['azureAsyncOperation']: + self._wait_for_rename(result['azureAsyncOperation']) + self.cmd('mysql flexible-server wait -g {rg} -n {new_name} --custom "state==\'Ready\'"') + renamed = self.cmd('mysql flexible-server show -g {rg} -n {new_name}').get_output_in_json() + self.assertEqual(source['id'].rsplit('/', 1)[0] + '/' + self.kwargs['new_name'], renamed['id']) + self.assertTrue(renamed['fullyQualifiedDomainName'].startswith(self.kwargs['new_name'] + '.')) + self.assertNotEqual(source['fullyQualifiedDomainName'], renamed['fullyQualifiedDomainName']) + self.assertEqual('Ready', renamed['state']) + for attribute in ('location', 'version', 'sku', 'storage'): + self.assertEqual(source[attribute], renamed[attribute], attribute) + return renamed + + def _wait_for_rename(self, operation_url): + deadline = time.monotonic() + 7200 + while True: + operation = self.cmd('rest --method get --url "{}"'.format(operation_url)).get_output_in_json() + self.assertNotIn(operation['status'], ('Failed', 'Canceled'), operation) + if operation['status'] == 'Succeeded': + return + self.assertLess(time.monotonic(), deadline, 'Rename did not complete within two hours.') + time.sleep(10) + + def _test_replication(self, resource_group, replica_location): + source = self._create_server(resource_group) + self.kwargs.update(source_id=source['id'], replica_location=replica_location, + replica_name=self.create_random_name('clireplica', 20)) + self.cmd('mysql flexible-server replica create -g {rg} --replica-name {replica_name} ' + '--source-server {source_id} --location {replica_location}') + replica = self.cmd('mysql flexible-server show -g {rg} -n {replica_name}').get_output_in_json() + renamed_replica = self._rename(replica) + self.assertEqual(source['id'].lower(), renamed_replica['sourceServerResourceId'].lower()) + self.assertEqual('Replica', renamed_replica['replicationRole']) + + renamed_primary = self._rename(source) + self.kwargs.update(replica_id=renamed_replica['id'], primary_name=renamed_primary['name']) + replica = self.cmd('mysql flexible-server show --ids {replica_id}').get_output_in_json() + self.assertEqual(renamed_primary['id'].lower(), replica['sourceServerResourceId'].lower()) + replicas = self.cmd( + 'mysql flexible-server replica list -g {rg} -n {primary_name}').get_output_in_json() + self.assertIn(replica['id'].lower(), [item['id'].lower() for item in replicas])