From 918665f2c24cbff88dda4d3529daccd800496dfb Mon Sep 17 00:00:00 2001 From: Jordan Selig Date: Thu, 26 Mar 2026 10:53:30 -0400 Subject: [PATCH] [App Service] Fix #16111: Add `az webapp deployment slot copy` command (preview) Add new command to copy content and configuration from one deployment slot to another using the REST API slotcopy endpoint. Unlike swap, this is a one-way operation that overwrites the target slot content. - Register command with is_preview=True in commands.py - Add --slot (source) and --target-slot (destination) parameters - Implementation uses send_raw_request since SDK lacks copy_slot method - Add help text with examples - Add unit tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../cli/command_modules/appservice/_help.py | 18 +++++ .../cli/command_modules/appservice/_params.py | 3 + .../command_modules/appservice/commands.py | 1 + .../cli/command_modules/appservice/custom.py | 21 ++++++ .../latest/test_webapp_commands_thru_mock.py | 73 +++++++++++++++++++ 5 files changed, 116 insertions(+) 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..97fac436b18 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -2052,6 +2052,24 @@ --target-slot production """ +helps['webapp deployment slot copy'] = """ +type: command +short-summary: Copy the content and configuration of a deployment slot to another slot. +long-summary: > + Copy site content from the source slot to the target slot. This is a one-way operation + that overwrites the target slot's content. Unlike swap, this does not exchange the slots. + The source slot is specified with --slot, and the destination is --target-slot (defaults to production). +examples: + - name: Copy a staging slot's content to production. + text: > + az webapp deployment slot copy -g MyResourceGroup -n MyUniqueApp --slot staging \\ + --target-slot production + - name: Copy production content to a staging slot. + text: > + az webapp deployment slot copy -g MyResourceGroup -n MyUniqueApp --slot production \\ + --target-slot staging +""" + helps['webapp deployment source'] = """ type: group short-summary: Manage web app deployment via source control. 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..431a531c42d 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -720,6 +720,9 @@ def load_arguments(self, _): c.argument('action', help="swap types. use 'preview' to apply target slot's settings on the source slot first; use 'swap' to complete it; use 'reset' to reset the swap", arg_type=get_enum_type(['swap', 'preview', 'reset'])) + with self.argument_context('webapp deployment slot copy') as c: + c.argument('slot', help='the name of the source slot to copy from') + c.argument('target_slot', help="the name of the destination slot to copy to, default to 'production'") with self.argument_context('webapp deployment github-actions')as c: c.argument('name', arg_type=webapp_name_arg_type) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/commands.py b/src/azure-cli/azure/cli/command_modules/appservice/commands.py index 47536f5a1df..145d9d5624a 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/commands.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/commands.py @@ -256,6 +256,7 @@ def load_command_table(self, _): g.custom_command('auto-swap', 'config_slot_auto_swap') g.custom_command('swap', 'swap_slot', exception_handler=ex_handler_factory()) g.custom_command('create', 'create_webapp_slot', exception_handler=ex_handler_factory()) + g.custom_command('copy', 'copy_slot', exception_handler=ex_handler_factory(), is_preview=True) with self.command_group('webapp deployment') as g: g.custom_command('list-publishing-profiles', 'list_publish_profiles') 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..2c170fee360 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -5610,6 +5610,27 @@ def swap_slot(cmd, resource_group_name, webapp, slot, target_slot=None, preserve return None +def copy_slot(cmd, resource_group_name, webapp, slot, target_slot=None): + from azure.cli.core.commands.client_factory import get_subscription_id + client = web_client_factory(cmd.cli_ctx) + target_slot = target_slot or 'production' + subscription_id = get_subscription_id(cmd.cli_ctx) + url = ("/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Web/sites/{}" + "/slots/{}/slotcopy?api-version={}").format( + subscription_id, resource_group_name, webapp, slot, + client.DEFAULT_API_VERSION) + body = json.dumps({"targetSlot": target_slot, "siteConfig": {}}) + response = send_raw_request(cmd.cli_ctx, method='post', url=url, body=body) + if response.status_code == 200: + return response.json() if response.text else None + if response.status_code == 202: + logger.warning("Slot copy operation accepted and is in progress. " + "Content from slot '%s' is being copied to '%s'.", + slot, target_slot) + return None + return response.json() if response.text else None + + def delete_slot(cmd, resource_group_name, webapp, slot): client = web_client_factory(cmd.cli_ctx) # TODO: once swagger finalized, expose other parameters like: delete_all_slots, etc... 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..4726ec59d0e 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 @@ -30,6 +30,7 @@ list_snapshots, restore_snapshot, create_managed_ssl_cert, + copy_slot, add_github_actions, update_app_settings, update_application_settings_polling, @@ -644,5 +645,77 @@ def __init__(self, status_code): self.status_code = status_code +class TestCopySlot(unittest.TestCase): + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory', autospec=True) + def test_copy_slot_success(self, client_factory_mock, send_raw_request_mock): + """Test copy_slot sends correct REST call and returns on 200.""" + client = mock.MagicMock() + client.DEFAULT_API_VERSION = '2024-04-01' + 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 + + response = mock.MagicMock() + response.status_code = 200 + response.text = '{"status": "completed"}' + response.json.return_value = {"status": "completed"} + send_raw_request_mock.return_value = response + + result = copy_slot(cmd_mock, 'rg1', 'myapp', 'staging', 'production') + self.assertEqual(result, {"status": "completed"}) + send_raw_request_mock.assert_called_once() + call_args = send_raw_request_mock.call_args + self.assertIn('/slotcopy', call_args[1].get('url', '') or str(call_args)) + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory', autospec=True) + def test_copy_slot_accepted(self, client_factory_mock, send_raw_request_mock): + """Test copy_slot returns None on 202 accepted.""" + client = mock.MagicMock() + client.DEFAULT_API_VERSION = '2024-04-01' + 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 + + response = mock.MagicMock() + response.status_code = 202 + response.text = '' + send_raw_request_mock.return_value = response + + result = copy_slot(cmd_mock, 'rg1', 'myapp', 'staging') + self.assertIsNone(result) + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory', autospec=True) + def test_copy_slot_default_target(self, client_factory_mock, send_raw_request_mock): + """Test copy_slot defaults target_slot to 'production'.""" + client = mock.MagicMock() + client.DEFAULT_API_VERSION = '2024-04-01' + 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 + + response = mock.MagicMock() + response.status_code = 200 + response.text = '{}' + response.json.return_value = {} + send_raw_request_mock.return_value = response + + copy_slot(cmd_mock, 'rg1', 'myapp', 'staging') + call_args = send_raw_request_mock.call_args + body_arg = call_args[1].get('body', '') + import json + body = json.loads(body_arg) + self.assertEqual(body['targetSlot'], 'production') + + if __name__ == '__main__': unittest.main()