Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/azure-cli/azure/cli/command_modules/appservice/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +2069 to +2070
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This help example uses --slot production, but elsewhere in this module production is represented by omitting --slot / passing slot=None (e.g., swap preview uses if slot is None:). Unless copy_slot explicitly handles production as a special case, this example will likely not work and should be adjusted to match the actual supported syntax/behavior.

Suggested change
az webapp deployment slot copy -g MyResourceGroup -n MyUniqueApp --slot production \\
--target-slot staging
az webapp deployment slot copy -g MyResourceGroup -n MyUniqueApp --target-slot staging

Copilot uses AI. Check for mistakes.
"""

helps['webapp deployment source'] = """
type: group
short-summary: Manage web app deployment via source control.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'")
Comment on lines +724 to +725
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Help text says "default to 'production'"; grammatically this should be "defaults to 'production'" (and similarly in other new/updated strings) to read correctly in --help output.

Copilot uses AI. Check for mistakes.

with self.argument_context('webapp deployment github-actions')as c:
c.argument('name', arg_type=webapp_name_arg_type)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
21 changes: 21 additions & 0 deletions src/azure-cli/azure/cli/command_modules/appservice/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +5616 to +5617
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The linked issue #16111’s proposed behavior states the target slot cannot be production, but this command defaults target_slot to 'production' and the help text/examples encourage copying to production. Please confirm the REST API actually allows production as a target; if it doesn't, add validation to block --target-slot production and update the default/help accordingly.

Copilot uses AI. Check for mistakes.
url = ("/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Web/sites/{}"
"/slots/{}/slotcopy?api-version={}").format(
subscription_id, resource_group_name, webapp, slot,
client.DEFAULT_API_VERSION)
Comment on lines +5618 to +5621
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--slot production is documented as a supported source (see help examples), but this implementation always calls the slot-scoped ARM resource /sites/{name}/slots/{slot}/slotcopy. In App Service, the production slot is the site resource itself (not a slots/production child), so this will likely 404 or invoke the wrong endpoint when slot is production. Consider normalizing slot=='production' (or empty) to the production endpoint (typically /sites/{name}/slotcopy) or explicitly validating and rejecting production if the REST API only supports slot-to-slot copy.

Suggested change
url = ("/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Web/sites/{}"
"/slots/{}/slotcopy?api-version={}").format(
subscription_id, resource_group_name, webapp, slot,
client.DEFAULT_API_VERSION)
# In App Service, the production slot is the site resource itself (no 'slots/production' child).
# Use the site-level slotcopy endpoint when the source is production, otherwise use the slot-scoped endpoint.
is_production_source = not slot or str(slot).lower() == 'production'
base_path = ("/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Web/sites/{}"
.format(subscription_id, resource_group_name, webapp))
if is_production_source:
url = "{}/slotcopy?api-version={}".format(base_path, client.DEFAULT_API_VERSION)
else:
url = "{}/slots/{}/slotcopy?api-version={}".format(base_path, slot, client.DEFAULT_API_VERSION)

Copilot uses AI. Check for mistakes.
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...
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
list_snapshots,
restore_snapshot,
create_managed_ssl_cert,
copy_slot,
add_github_actions,
update_app_settings,
update_application_settings_polling,
Expand Down Expand Up @@ -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
Comment on lines +668 to +713
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests cover 200/202 and default target_slot, but they don't cover the documented --slot production case. Add a unit test asserting the correct URL/path behavior for a production source (or, if production source isn't supported, a test that it fails/validates accordingly) so this doesn't regress.

Copilot uses AI. Check for mistakes.
body_arg = call_args[1].get('body', '')
import json
body = json.loads(body_arg)
self.assertEqual(body['targetSlot'], 'production')


if __name__ == '__main__':
unittest.main()
Loading