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..ef4951ee67d 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,28 @@ 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 _validate_flex_migration_target_arguments(in_place, name, resource_group): + 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.") + elif 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.") + + +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_flex_migration_target_arguments(in_place, name, resource_group) web_client = get_mgmt_service_client(cmd.cli_ctx, WebSiteManagementClient) @@ -1286,6 +1304,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 +1332,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 +1403,132 @@ 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 + + print(f"\nUpgrading function app '{source_name}' to Flex Consumption in place...") + + 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 + + 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} + + 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 + + function_app_config = {} + function_app_config["deployment"] = { + "storage": { + "type": "blobContainer", + "value": deployment_config_storage_value, + "authentication": deployment_storage_auth_config + } + } + + 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} + + 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 the existing site + 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" + # serverFarmId left unchanged; the orchestrator creates the FC plan on the same stamp. + + # Clear CV1 siteConfig properties that the server rejects on a Flex PUT. + if site.site_config is not None: + site.site_config.linux_fx_version = None + site.site_config.function_app_scale_limit = None + + # Set app settings via dedicated API BEFORE the PUT — the orchestrator's + # MigrateDeploymentAsync reads from the appsettings store, not from the PUT body. + if app_settings_to_add: + from azure.mgmt.web.models import StringDictionary + existing_settings = flex_client.web_apps.list_application_settings(source_resource_group, source_name) + settings_dict = existing_settings.properties or {} + for setting in app_settings_to_add: + settings_dict[setting['name']] = setting['value'] + flex_client.web_apps.update_application_settings( + source_resource_group, source_name, StringDictionary(properties=settings_dict)) + + # SkuTransitionResolver reads top-level sku.name, not properties.sku — inject manually. + site_dict = site.as_dict() + site_dict['sku'] = {'name': 'FlexConsumption'} + + print(f"Submitting upgrade request for '{source_name}'...") + poller = flex_client.web_apps.begin_create_or_update(source_resource_group, source_name, site_dict) + 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))