Skip to content
Draft
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
10 changes: 7 additions & 3 deletions src/azure-cli/azure/cli/command_modules/appservice/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'] = """
Expand Down
13 changes: 11 additions & 2 deletions src/azure-cli/azure/cli/command_modules/appservice/_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<name>=<value>`")
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.")
Expand Down
159 changes: 157 additions & 2 deletions src/azure-cli/azure/cli/command_modules/appservice/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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."
Expand All @@ -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:
Expand Down Expand Up @@ -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}'...")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading