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 d8fb28fa9a9..ac4d1c90da0 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -1216,20 +1216,24 @@ helps['functionapp flex-migration'] = """ type: group -short-summary: Manage migration of Linux Consumption function apps to the Flex Consumption plan. +short-summary: Manage migration of Linux Consumption function apps to the Flex Consumption plan — side-by-side (new app) or in place (same app). """ helps['functionapp flex-migration start'] = """ type: command -short-summary: Create a Flex Consumption app with the same settings as the provided Linux Consumption function app. +short-summary: Migrate a Linux Consumption function app to Flex Consumption. Supports side-by-side (new app) or in-place (same app) upgrade. examples: - - name: Migrate a Linux Consumption function app to the Flex Consumption plan. + - name: Migrate a Linux Consumption function app to the Flex Consumption plan (side-by-side, creates a new app). text: > az functionapp flex-migration start --source-name MyLinuxConsumptionApp --source-resource-group MyLinuxConsumptionResourceGroup --name MyFunctionApp --resource-group MyResourceGroup --storage-account MyStorageAccount - name: Migrate a Linux Consumption function app to the Flex Consumption plan without migrating managed identity configurations. text: > az functionapp flex-migration start --source-name MyLinuxConsumptionApp --source-resource-group MyLinuxConsumptionResourceGroup --name MyFunctionApp --resource-group MyResourceGroup --storage-account MyStorageAccount --skip-managed-identities + + - name: Upgrade a Linux Consumption function app to Flex Consumption in place (same app, same name). + text: > + az functionapp flex-migration start --source-name MyLinuxConsumptionApp --source-resource-group MyResourceGroup --in-place """ helps['functionapp flex-migration list'] = """ 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 775fe497149..49c5eb4bbe4 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -439,10 +439,19 @@ def load_arguments(self, _): with self.argument_context('functionapp flex-migration start') as c: c.argument('source_resource_group', help='The resource group of the source function app to migrate from.') c.argument('source_name', help='The name of the source function app to migrate from.') - c.argument('resource_group', help='The resource group of the target function app to migrate to.') - c.argument('name', help='The name of the target function app to migrate to.') + c.argument('resource_group', help='The resource group of the target function app to migrate to. Not applicable with --in-place.') + c.argument('name', help='The name of the target function app to migrate to. Not applicable with --in-place.') c.argument('storage_account', help='The storage account to use for the target function app. If no storage account is provided, the storage account of the source function app will be used.') c.argument('maximum_instance_count', type=int, help="The maximum number of instances.") + c.argument('in_place', options_list=['--in-place', '-i'], arg_type=get_three_state_flag(), help="Upgrade the source app to Flex Consumption in place (same app, same name, same hostname). Cannot be used with --name or --resource-group.", is_preview=True) + c.argument('instance_memory', type=int, help="The instance memory size in MB. See https://aka.ms/flex-instance-sizes for more information on the supported values.") + c.argument('always_ready_instances', nargs='+', help="space-separated configuration for the number of pre-allocated instances in the format `=`") + c.argument('deployment_storage_name', options_list=['--deployment-storage-name', '--dsn'], help="The deployment storage account name.") + c.argument('deployment_storage_container_name', options_list=['--deployment-storage-container-name', '--dscn'], help="The deployment storage account container name.") + c.argument('deployment_storage_auth_type', options_list=['--deployment-storage-auth-type', '--dsat'], arg_type=get_enum_type(DEPLOYMENT_STORAGE_AUTH_TYPES), help="The deployment storage account authentication type.") + c.argument('deployment_storage_auth_value', options_list=['--deployment-storage-auth-value', '--dsav'], help="The deployment storage account authentication value. For the user-assigned managed identity authentication type, " + "this should be the user assigned identity resource id. For the storage account connection string authentication type, this should be the name of the app setting that will contain the storage account connection " + "string. For the system assigned managed-identity authentication type, this parameter is not applicable and should be left empty.") c.argument('skip_managed_identities', options_list=['--skip-managed-identities', '--smi'], arg_type=get_three_state_flag(return_label=True), help="Skip migrating managed identities.") c.argument('skip_access_restrictions', options_list=['--skip-access-restrictions', '--sar'], arg_type=get_three_state_flag(return_label=True), help="Skip migrating access restrictions.") c.argument('skip_storage_mount', options_list=['--skip-storage-mount', '--ssm'], arg_type=get_three_state_flag(return_label=True), help="Skip migrating storage mounts.") 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 0f247b322e2..d2d2d34a383 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -1274,10 +1274,26 @@ def get_storage_account_from_functionapp(cmd, resource_group_name, name): .format(storage_account_name, name)) -def migrate_consumption_to_flex(cmd, source_resource_group, source_name, resource_group, name, storage_account=None, +def migrate_consumption_to_flex(cmd, source_resource_group, source_name, resource_group=None, name=None, + storage_account=None, maximum_instance_count=None, skip_managed_identities=False, skip_access_restrictions=False, skip_storage_mount=False, skip_hostnames=False, - skip_cors=False): + skip_cors=False, in_place=False, + instance_memory=None, always_ready_instances=None, + deployment_storage_name=None, deployment_storage_container_name=None, + deployment_storage_auth_type=None, deployment_storage_auth_value=None): + + # Validate --in-place mutual exclusions + if in_place: + if name or resource_group: + raise MutuallyExclusiveArgumentError( + "'--in-place' cannot be used with '--name' or '--resource-group'. " + "In-place upgrade operates on the source app directly.") + else: + if not name or not resource_group: + raise RequiredArgumentMissingError( + "'--name' and '--resource-group' are required for side-by-side migration. " + "Use '--in-place' to upgrade the source app directly.") web_client = get_mgmt_service_client(cmd.cli_ctx, WebSiteManagementClient) @@ -1286,6 +1302,11 @@ def migrate_consumption_to_flex(cmd, source_resource_group, source_name, resourc flex_regions = [region['name'] for region in list_flexconsumption_locations(cmd)] source = web_client.web_apps.get(source_resource_group, source_name) + # Check if already on Flex (in-place only) + if in_place and is_flex_functionapp(cmd.cli_ctx, source_resource_group, source_name): + raise ValidationError("The site '{}' is already on Flex Consumption. No upgrade needed." + .format(source_name)) + if not _is_linux_consumption_function_app(cmd, source): raise ValidationError("The site '{}' is not on a Linux Dynamic (Consumption) plan. Flex Consumption " "migration is only supported for Function Apps on Linux Consumption plans." @@ -1309,6 +1330,15 @@ def migrate_consumption_to_flex(cmd, source_resource_group, source_name, resourc source_runtime = source_runtime_info['app_runtime'] source_runtime_version = source_runtime_info['app_runtime_version'] + # Branch: in-place upgrade vs side-by-side migration + if in_place: + return _upgrade_consumption_to_flex_in_place( + cmd, source, source_resource_group, source_name, + storage_account, deployment_storage_name, deployment_storage_container_name, + deployment_storage_auth_type, deployment_storage_auth_value, + source_runtime, source_runtime_version, + instance_memory, maximum_instance_count, always_ready_instances) + print(f"\nCreating Flex Consumption function app '{name}' in resource group '{resource_group}'...") if not storage_account: @@ -1371,6 +1401,131 @@ def migrate_consumption_to_flex(cmd, source_resource_group, source_name, resourc return get_functionapp(cmd, resource_group, name) +def _upgrade_consumption_to_flex_in_place(cmd, source, source_resource_group, source_name, + storage_account, deployment_storage_name, + deployment_storage_container_name, + deployment_storage_auth_type, deployment_storage_auth_value, + source_runtime, source_runtime_version, + instance_memory, maximum_instance_count, always_ready_instances): + """Upgrade an existing CV1 Linux Consumption function app to Flex Consumption in place.""" + from azure.mgmt.web.models import SiteProperties + from ._validators import validate_and_convert_to_int + + print(f"\nUpgrading function app '{source_name}' to Flex Consumption in place...") + + # Resolve deployment storage (same logic as create) + if not storage_account: + storage_account = get_storage_account_from_functionapp(cmd, source_resource_group, source_name) + storage_account_name = parse_resource_id(storage_account)['name'] if is_valid_resource_id(storage_account) \ + else storage_account + + if not deployment_storage_name: + deployment_storage_name = storage_account_name + + deployment_storage = _validate_and_get_deployment_storage(cmd.cli_ctx, source_resource_group, + deployment_storage_name) + + deployment_storage_container = _get_or_create_deployment_storage_container( + cmd, source_resource_group, source_name, deployment_storage_name, deployment_storage_container_name) + deployment_storage_container_name = deployment_storage_container.name + + endpoints = deployment_storage.primary_endpoints + deployment_config_storage_value = getattr(endpoints, 'blob') + deployment_storage_container_name + + # Build deployment storage auth config + deployment_storage_auth_type = deployment_storage_auth_type or 'StorageAccountConnectionString' + + if deployment_storage_auth_value and deployment_storage_auth_type == 'SystemAssignedIdentity': + raise ArgumentUsageError( + '--deployment-storage-auth-value is only a valid input when ' + '--deployment-storage-auth-type is set to UserAssignedIdentity or StorageAccountConnectionString. ' + 'Please try again with --deployment-storage-auth-type set to UserAssignedIdentity or ' + 'StorageAccountConnectionString.') + + deployment_storage_auth_config = {"type": deployment_storage_auth_type} + + # Handle auth type-specific configuration + app_settings_to_add = [] + if deployment_storage_auth_type == 'UserAssignedIdentity': + deployment_storage_user_assigned_identity = _get_or_create_user_assigned_identity( + cmd, source_resource_group, source_name, deployment_storage_auth_value, source.location) + deployment_storage_auth_value = deployment_storage_user_assigned_identity.id + deployment_storage_auth_config["userAssignedIdentityResourceId"] = deployment_storage_auth_value + elif deployment_storage_auth_type == 'StorageAccountConnectionString': + deployment_storage_conn_string = _get_storage_connection_string(cmd.cli_ctx, deployment_storage) + conn_string_app_setting = deployment_storage_auth_value or 'DEPLOYMENT_STORAGE_CONNECTION_STRING' + app_settings_to_add.append({'name': conn_string_app_setting, 'value': deployment_storage_conn_string}) + deployment_storage_auth_value = conn_string_app_setting + deployment_storage_auth_config["storageAccountConnectionStringName"] = deployment_storage_auth_value + + # Build functionAppConfig + function_app_config = {} + function_app_config["deployment"] = { + "storage": { + "type": "blobContainer", + "value": deployment_config_storage_value, + "authentication": deployment_storage_auth_config + } + } + + # Resolve runtime from the source app's existing runtime + runtime_helper = _FlexFunctionAppStackRuntimeHelper(cmd, source.location, source_runtime, source_runtime_version) + matched_runtime = runtime_helper.resolve(source_runtime, source_runtime_version) + flex_sku = matched_runtime.sku + + runtime = flex_sku['functionAppConfigProperties']['runtime']['name'] + version = flex_sku['functionAppConfigProperties']['runtime']['version'] + function_app_config["runtime"] = {"name": runtime, "version": version} + + # Scale and concurrency + always_ready_dict = _parse_key_value_pairs(always_ready_instances) + always_ready_config = [] + for key, value in always_ready_dict.items(): + always_ready_config.append({ + "name": key, + "instanceCount": max(0, validate_and_convert_to_int(key, value)) + }) + + default_instance_memory = [x for x in flex_sku['instanceMemoryMB'] if x['isDefault'] is True][0] + function_app_config["scaleAndConcurrency"] = { + "maximumInstanceCount": maximum_instance_count or flex_sku['maximumInstanceCount']['defaultValue'], + "instanceMemoryMB": instance_memory or default_instance_memory['size'], + "alwaysReady": always_ready_config + } + + # GET-mutate-PUT against the EXISTING site (no new plan, no new app) + flex_client = web_client_factory(cmd.cli_ctx, api_version='2025-05-01') + site = flex_client.web_apps.get(source_resource_group, source_name) + + if site.properties is None: + site.properties = SiteProperties() + site.properties.function_app_config = function_app_config + site.properties.sku = "FlexConsumption" + # NOTE: serverFarmId is deliberately left unchanged (existing CV1 plan id). + # The orchestrator (SkuTransitionResolver) owns FC server-farm creation on the same stamp. + + # Add deployment storage connection string app setting if using connection string auth + if app_settings_to_add: + from azure.mgmt.web.models import NameValuePair + if site.site_config is None: + from azure.mgmt.web.models import SiteConfig + site.site_config = SiteConfig() + if site.site_config.app_settings is None: + site.site_config.app_settings = [] + for setting in app_settings_to_add: + site.site_config.app_settings.append(NameValuePair(name=setting['name'], value=setting['value'])) + + print(f"Submitting upgrade request for '{source_name}'...") + poller = flex_client.web_apps.begin_create_or_update(source_resource_group, source_name, site) + LongRunningOperation(cmd.cli_ctx)(poller) + + print(f"\nUpgrade complete. Function app '{source_name}' is now on Flex Consumption." + f"\nNote: The app may take a few moments to become fully operational on Flex infrastructure." + f"\nA 7-day revert window is available via ACIS if needed.") + + return get_functionapp(cmd, source_resource_group, source_name) + + def _migrate_app_settings(cmd, source_resource_group, source_name, resource_group, name, storage_account): print(f"\nMigrating app settings from source function app '{source_name}' to target function app '{name}'...") diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_functionapp_commands.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_functionapp_commands.py index 4e8f07f9ed9..6932c290fe5 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_functionapp_commands.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_functionapp_commands.py @@ -1344,6 +1344,62 @@ def test_functionapp_flex_migration_cors(self, resource_group, resource_group2, self.assertTrue(tgt_cors_config['supportCredentials']) +class FunctionAppFlexMigrationInPlaceTest(LiveScenarioTest): + @ResourceGroupPreparer(location=FLEX_ASP_LOCATION_FUNCTIONAPP) + @StorageAccountPreparer() + def test_functionapp_flex_migration_in_place(self, resource_group, storage_account): + """In-place upgrade: CV1 Linux Consumption → Flex Consumption (same site, same name).""" + src_name = self.create_random_name('inplace-func', 24) + + # Create a Linux Consumption function app (CV1) + self.cmd('functionapp create -g {} -n {} -c {} -s {} --os-type linux --runtime python --runtime-version 3.11 --functions-version 4' + .format(resource_group, src_name, FLEX_ASP_LOCATION_FUNCTIONAPP, storage_account)) + + # Verify it's on Dynamic (Consumption) SKU before upgrade + src_app = self.cmd('functionapp show -g {} -n {}'.format(resource_group, src_name)).get_output_in_json() + self.assertEqual(src_app['kind'], 'functionapp,linux') + + # Run in-place upgrade + result = self.cmd( + 'functionapp flex-migration start --source-resource-group {} --source-name {} --in-place' + .format(resource_group, src_name) + ).get_output_in_json() + + # Verify the app is now Flex Consumption + self.assertEqual(result['name'], src_name) + upgraded_app = self.cmd('functionapp show -g {} -n {}'.format(resource_group, src_name)).get_output_in_json() + # After upgrade, the site should have Flex properties + self.assertIsNotNone(upgraded_app.get('properties', {}).get('functionAppConfig')) + + @ResourceGroupPreparer(location=FLEX_ASP_LOCATION_FUNCTIONAPP) + @StorageAccountPreparer() + def test_functionapp_flex_migration_in_place_with_deployment_storage(self, resource_group, storage_account): + """In-place upgrade with explicit deployment storage arguments.""" + src_name = self.create_random_name('inplace-ds', 24) + + self.cmd('functionapp create -g {} -n {} -c {} -s {} --os-type linux --runtime python --runtime-version 3.11 --functions-version 4' + .format(resource_group, src_name, FLEX_ASP_LOCATION_FUNCTIONAPP, storage_account)) + + result = self.cmd( + 'functionapp flex-migration start --source-resource-group {} --source-name {} --in-place ' + '--deployment-storage-name {} --deployment-storage-container-name mycontainer' + .format(resource_group, src_name, storage_account) + ).get_output_in_json() + + self.assertEqual(result['name'], src_name) + + def test_functionapp_flex_migration_in_place_rejects_target_args(self): + """--in-place with --name should fail.""" + with self.assertRaises(SystemExit): + self.cmd('functionapp flex-migration start --source-resource-group rg --source-name app ' + '--in-place --name target-app --resource-group target-rg') + + def test_functionapp_flex_migration_side_by_side_requires_target_args(self): + """Side-by-side without --name/--resource-group should fail.""" + with self.assertRaises(SystemExit): + self.cmd('functionapp flex-migration start --source-resource-group rg --source-name app') + + class FunctionAppFlex(LiveScenarioTest): def test_functionapp_list_flexconsumption_locations(self): locations = self.cmd('functionapp list-flexconsumption-locations').get_output_in_json() diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_functionapp_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_functionapp_commands_thru_mock.py index 2174b32810d..15f82eb4adb 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_functionapp_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_functionapp_commands_thru_mock.py @@ -18,10 +18,12 @@ config_source_control, validate_app_settings_in_scm, update_container_settings_functionapp, - list_function_keys) + list_function_keys, + migrate_consumption_to_flex) from azure.cli.core.profiles import ResourceType from azure.cli.core.azclierror import (AzureInternalError, UnclassifiedUserFault) -from azure.cli.core.azclierror import ResourceNotFoundError +from azure.cli.core.azclierror import (ResourceNotFoundError, MutuallyExclusiveArgumentError, + RequiredArgumentMissingError, ValidationError) TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), '..')) @@ -889,3 +891,105 @@ def test_list_function_keys_uses_properties_when_sdk_returns_enveloped_response( self.assertEqual(result, {'default': 'abc'}) client_mock.web_apps.list_function_keys_slot.assert_called_once_with('rg', 'app', 'httpget', 'staging') client_mock.web_apps.list_function_keys.assert_not_called() + + +class TestFlexMigrationInPlaceMocked(unittest.TestCase): + """Unit tests for the --in-place flag on flex-migration start.""" + + def test_in_place_rejects_target_name_arg(self): + """--in-place with --name should raise MutuallyExclusiveArgumentError.""" + cmd_mock = _get_test_cmd() + with self.assertRaises(MutuallyExclusiveArgumentError): + migrate_consumption_to_flex(cmd_mock, + source_resource_group='src-rg', + source_name='src-app', + resource_group=None, + name='target-app', + in_place=True) + + def test_in_place_rejects_target_resource_group_arg(self): + """--in-place with --resource-group should raise MutuallyExclusiveArgumentError.""" + cmd_mock = _get_test_cmd() + with self.assertRaises(MutuallyExclusiveArgumentError): + migrate_consumption_to_flex(cmd_mock, + source_resource_group='src-rg', + source_name='src-app', + resource_group='target-rg', + name=None, + in_place=True) + + def test_in_place_rejects_both_target_args(self): + """--in-place with both --name and --resource-group should raise MutuallyExclusiveArgumentError.""" + cmd_mock = _get_test_cmd() + with self.assertRaises(MutuallyExclusiveArgumentError): + migrate_consumption_to_flex(cmd_mock, + source_resource_group='src-rg', + source_name='src-app', + resource_group='target-rg', + name='target-app', + in_place=True) + + def test_side_by_side_requires_name(self): + """Side-by-side (no --in-place) without --name should raise RequiredArgumentMissingError.""" + cmd_mock = _get_test_cmd() + with self.assertRaises(RequiredArgumentMissingError): + migrate_consumption_to_flex(cmd_mock, + source_resource_group='src-rg', + source_name='src-app', + resource_group='target-rg', + name=None, + in_place=False) + + def test_side_by_side_requires_resource_group(self): + """Side-by-side (no --in-place) without --resource-group should raise RequiredArgumentMissingError.""" + cmd_mock = _get_test_cmd() + with self.assertRaises(RequiredArgumentMissingError): + migrate_consumption_to_flex(cmd_mock, + source_resource_group='src-rg', + source_name='src-app', + resource_group=None, + name='target-app', + in_place=False) + + @mock.patch('azure.cli.command_modules.appservice.custom.get_mgmt_service_client') + @mock.patch('azure.cli.command_modules.appservice.custom.list_flexconsumption_locations', return_value=[{'name': 'eastus'}]) + @mock.patch('azure.cli.command_modules.appservice.custom.is_flex_functionapp', return_value=True) + def test_in_place_rejects_already_flex(self, is_flex_mock, list_locations_mock, get_client_mock): + """--in-place on an already-Flex app should raise ValidationError.""" + cmd_mock = _get_test_cmd() + # Mock the web client to return a site + client_mock = mock.MagicMock() + site_mock = mock.MagicMock() + site_mock.kind = 'functionapp,linux' + site_mock.name = 'src-app' + client_mock.web_apps.get.return_value = site_mock + get_client_mock.return_value = client_mock + + with self.assertRaises(ValidationError) as ctx: + migrate_consumption_to_flex(cmd_mock, + source_resource_group='src-rg', + source_name='src-app', + in_place=True) + self.assertIn('already on Flex Consumption', str(ctx.exception)) + + @mock.patch('azure.cli.command_modules.appservice.custom.get_mgmt_service_client') + @mock.patch('azure.cli.command_modules.appservice.custom.list_flexconsumption_locations', return_value=[{'name': 'eastus'}]) + @mock.patch('azure.cli.command_modules.appservice.custom.is_flex_functionapp', return_value=False) + @mock.patch('azure.cli.command_modules.appservice.custom._is_linux_consumption_function_app', return_value=False) + def test_in_place_rejects_non_consumption(self, is_linux_consumption_mock, is_flex_mock, + list_locations_mock, get_client_mock): + """--in-place on a non-Consumption app should raise ValidationError.""" + cmd_mock = _get_test_cmd() + client_mock = mock.MagicMock() + site_mock = mock.MagicMock() + site_mock.kind = 'functionapp' + site_mock.name = 'src-app' + client_mock.web_apps.get.return_value = site_mock + get_client_mock.return_value = client_mock + + with self.assertRaises(ValidationError) as ctx: + migrate_consumption_to_flex(cmd_mock, + source_resource_group='src-rg', + source_name='src-app', + in_place=True) + self.assertIn('not on a Linux Dynamic (Consumption) plan', str(ctx.exception))