From 9c882aaa38babfeb1747c832bb44fde9eda7b7e8 Mon Sep 17 00:00:00 2001 From: PragatiKushwaha Date: Tue, 29 Sep 2026 13:51:21 +0530 Subject: [PATCH 1/5] [App Service] `az functionapp create`, `az functionapp deployment config set`: Add Flex Consumption Registry deployment storage support Add --deployment-image, --deployment-image-auth-type, --deployment-image-identity, --deployment-image-username-setting, --deployment-image-password-setting and --deployment-image-server-url so Flex Consumption apps can run a container image from functionAppConfig.deployment.storage of type Registry (Microsoft.Web 2025-05-01). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../command_modules/appservice/_constants.py | 8 + .../cli/command_modules/appservice/_help.py | 19 + .../cli/command_modules/appservice/_params.py | 20 +- .../cli/command_modules/appservice/custom.py | 180 +++++++-- .../tests/latest/test_functionapp_commands.py | 59 +++ .../test_functionapp_commands_thru_mock.py | 368 +++++++++++++++++- .../cli/command_modules/appservice/utils.py | 4 +- 7 files changed, 630 insertions(+), 28 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_constants.py b/src/azure-cli/azure/cli/command_modules/appservice/_constants.py index 98fcae8db44..497a8756350 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_constants.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_constants.py @@ -147,6 +147,14 @@ def __init__(self): DEPLOYMENT_STORAGE_AUTH_TYPES = ['SystemAssignedIdentity', 'UserAssignedIdentity', 'StorageAccountConnectionString'] +FLEX_REGISTRY_AUTH_TYPES = ['Anonymous', 'SystemAssignedIdentity', 'UserAssignedIdentity', 'Basic'] + +FLEX_REGISTRY_API_VERSION = '2025-05-01' + +# Registry apps have no runtime stack to supply scale defaults, so use the Flex Consumption stack defaults. +FLEX_DEFAULT_MAXIMUM_INSTANCE_COUNT = 100 +FLEX_DEFAULT_INSTANCE_MEMORY_MB = 2048 + UPDATE_STRATEGY_TYPES = ['Recreate', 'RollingUpdate'] STORAGE_BLOB_DATA_CONTRIBUTOR_ROLE_ID = 'ba92f5b4-2d11-453d-a403-e96b0029c9fe' 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 7240edab63e..30eef8e3122 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -596,11 +596,27 @@ helps['functionapp deployment config set'] = """ type: command short-summary: Update an existing function app's deployment configuration. +long-summary: > + Use the --deployment-storage-* arguments for blob container deployment storage, or the --deployment-image + arguments for a Flex Consumption app that runs a container image (Registry deployment storage). Registry settings + are stored in the app's functionAppConfig and are separate from the legacy Linux container settings managed by + `az functionapp config container`. Switching to Registry removes the + functionAppConfig runtime. The service accepting the configuration doesn't prove that the registry is reachable, + that access is authorized, or that deployment succeeds. The CLI doesn't track tags; setting the same tag again + doesn't pull a newer image. examples: - name: Set the function app's deployment storage. text: az functionapp deployment config set --name MyFunctionApp --resource-group MyResourceGroup --deployment-storage-name MyStorageAccount --deployment-storage-container-name MyStorageContainer - name: Set the function app's deployment storage authentication method. text: az functionapp deployment config set --name MyFunctionApp --resource-group MyResourceGroup --deployment-storage-auth-type userAssignedIdentity --deployment-storage-auth-value myAssignedId + - name: Run a public container image (Anonymous authentication). + text: az functionapp deployment config set --name MyFunctionApp --resource-group MyResourceGroup --deployment-image mcr.microsoft.com/azure-functions/dotnet-isolated:4-dotnet-isolated8.0 --deployment-image-auth-type Anonymous + - name: Pull a container image by digest with the app's system-assigned identity. + text: az functionapp deployment config set --name MyFunctionApp --resource-group MyResourceGroup --deployment-image myregistry.azurecr.io/myimage@sha256: --deployment-image-auth-type SystemAssignedIdentity + - name: Pull the container image with a user-assigned identity. + text: az functionapp deployment config set --name MyFunctionApp --resource-group MyResourceGroup --deployment-image-auth-type UserAssignedIdentity --deployment-image-identity /subscriptions//resourceGroups/MyResourceGroup/providers/Microsoft.ManagedIdentity/userAssignedIdentities/MyIdentity + - name: Pull the container image with a username and password stored in app settings (Basic authentication). + text: az functionapp deployment config set --name MyFunctionApp --resource-group MyResourceGroup --deployment-image-auth-type Basic --deployment-image-username-setting REGISTRY_USERNAME --deployment-image-password-setting REGISTRY_PASSWORD --deployment-image-server-url https://myregistry.azurecr.io """ helps['functionapp deployment config show'] = """ @@ -780,6 +796,9 @@ - name: Create a flex consumption function app. See https://aka.ms/flex-http-concurrency for more information on default http concurrency values. text: > az functionapp create -g MyResourceGroup --name MyUniqueAppName -s MyStorageAccount --flexconsumption-location northeurope --runtime java --instance-memory 2048 + - name: Create a flex consumption function app that runs a container image from Azure Container Registry, pulled with the app's system-assigned identity. The service accepting the configuration doesn't prove that the image can be pulled. + text: > + az functionapp create -g MyResourceGroup --name MyUniqueAppName -s MyStorageAccount --flexconsumption-location northeurope --deployment-image myregistry.azurecr.io/myimage:v1 --deployment-image-auth-type SystemAssignedIdentity --assign-identity [system] --role AcrPull --scope /subscriptions//resourceGroups/MyResourceGroup/providers/Microsoft.ContainerRegistry/registries/myregistry """ helps['functionapp delete'] = """ 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 52946dbb083..739fe28cbdd 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -19,7 +19,8 @@ from ._completers import get_hostname_completion_list from ._constants import (FUNCTIONS_VERSIONS, LOGICAPPS_NODE_RUNTIME_VERSIONS, WINDOWS_OS_NAME, LINUX_OS_NAME, - DEPLOYMENT_STORAGE_AUTH_TYPES, UPDATE_STRATEGY_TYPES, ISOLATED_V4_SKUS) + DEPLOYMENT_STORAGE_AUTH_TYPES, UPDATE_STRATEGY_TYPES, ISOLATED_V4_SKUS, + FLEX_REGISTRY_AUTH_TYPES) from ._validators import (validate_timeout_value, validate_site_create, validate_asp_create, validate_ase_create, validate_ip_address, @@ -1230,6 +1231,23 @@ def load_arguments(self, _): "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.") + for scope in ['functionapp create', 'functionapp deployment config set']: + with self.argument_context(scope, arg_group='Flex Registry Deployment') as c: + c.argument('deployment_image', options_list=['--deployment-image'], + help="Container image for a Flex Consumption app, e.g. `myregistry.azurecr.io/myimage:v1` or `myregistry.azurecr.io/myimage@sha256:`. " + "Saved unchanged as the app's Registry deployment storage; the CLI doesn't validate, resolve, or pull it. Unrelated to legacy Linux container settings.") + c.argument('deployment_image_auth_type', options_list=['--deployment-image-auth-type', '--diat'], arg_type=get_enum_type(FLEX_REGISTRY_AUTH_TYPES), + help="How the platform authenticates to the registry: Anonymous (public image), SystemAssignedIdentity, UserAssignedIdentity (requires --deployment-image-identity), " + "or Basic (requires --deployment-image-username-setting and --deployment-image-password-setting). The CLI doesn't assign identities or grant registry access.") + c.argument('deployment_image_identity', options_list=['--deployment-image-identity', '--dii'], + help="Resource ID of the user-assigned managed identity used to pull the image. Only valid with UserAssignedIdentity. Saved as provided; the CLI doesn't create, look up, or assign it.") + c.argument('deployment_image_username_setting', options_list=['--deployment-image-username-setting', '--dius'], + help="Name of the app setting that stores the registry username. Only valid with Basic.") + c.argument('deployment_image_password_setting', options_list=['--deployment-image-password-setting', '--dips'], + help="Name of the app setting that stores the registry password. Only valid with Basic. Pass the app setting name, not the password.") + c.argument('deployment_image_server_url', options_list=['--deployment-image-server-url', '--diurl'], + help="Registry server URL for Basic authentication, e.g. `https://myregistry.azurecr.io`. Optional; only valid with Basic.") + with self.argument_context('functionapp cors credentials') as c: c.argument('enable', help='enable/disable access-control-allow-credentials', arg_type=get_three_state_flag()) 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 218ed67f79f..3ae23080854 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -99,7 +99,8 @@ WINDOWS_FUNCTIONAPP_GITHUB_ACTIONS_WORKFLOW_TEMPLATE_PATH, DEFAULT_CENTAURI_IMAGE, VERSION_2022_09_01, FLEX_SUBNET_DELEGATION, RUNTIME_STATUS_TEXT_MAP, LANGUAGE_EOL_DEPRECATION_NOTICES, - STORAGE_BLOB_DATA_CONTRIBUTOR_ROLE_ID) + STORAGE_BLOB_DATA_CONTRIBUTOR_ROLE_ID, FLEX_REGISTRY_API_VERSION, + FLEX_DEFAULT_MAXIMUM_INSTANCE_COUNT, FLEX_DEFAULT_INSTANCE_MEMORY_MB) from ._github_oauth import (get_github_access_token, cache_github_token) from ._validators import validate_and_convert_to_int, validate_range_of_int_flag, _validate_asp_sku @@ -1693,10 +1694,7 @@ def _rollback_flex_deployment_storage_identity(cmd, resource_group_name, name, c def _build_flex_function_app_config(deployment_storage_value, deployment_storage_auth_config, flex_sku, instance_memory, maximum_instance_count, always_ready_instances): - always_ready_config = [{ - "name": key, - "instanceCount": max(0, validate_and_convert_to_int(key, value)) - } for key, value in _parse_key_value_pairs(always_ready_instances).items()] + always_ready_config = _build_flex_always_ready_config(always_ready_instances) default_instance_memory = [x for x in flex_sku['instanceMemoryMB'] if x['isDefault'] is True][0] runtime = flex_sku['functionAppConfigProperties']['runtime'] @@ -1720,6 +1718,70 @@ def _build_flex_function_app_config(deployment_storage_value, deployment_storage } +def _build_flex_always_ready_config(always_ready_instances): + return [{ + "name": key, + "instanceCount": max(0, validate_and_convert_to_int(key, value)) + } for key, value in _parse_key_value_pairs(always_ready_instances).items()] + + +def _is_flex_registry_storage(deployment_storage): + return (deployment_storage.get("type") or "").lower() == "registry" + + +def _build_flex_registry_authentication(auth_type, identity=None, username_setting=None, password_setting=None, + server_url=None): + """Return the Registry authentication object for exactly one mode, or None when no auth argument is given.""" + basic_args = (username_setting, password_setting, server_url) + if auth_type is None: + if identity is not None or any(arg is not None for arg in basic_args): + raise RequiredArgumentMissingError('--deployment-image-auth-type is required when specifying ' + 'Registry authentication arguments.') + return None + if identity is not None and auth_type != 'UserAssignedIdentity': + raise ArgumentUsageError('--deployment-image-identity is only valid with ' + '--deployment-image-auth-type UserAssignedIdentity.') + if any(arg is not None for arg in basic_args) and auth_type != 'Basic': + raise ArgumentUsageError('--deployment-image-username-setting, --deployment-image-password-setting and ' + '--deployment-image-server-url are only valid with ' + '--deployment-image-auth-type Basic.') + + authentication = {"type": auth_type} + if auth_type == 'UserAssignedIdentity': + if not identity: + raise RequiredArgumentMissingError('--deployment-image-identity is required with ' + '--deployment-image-auth-type UserAssignedIdentity.') + authentication["userAssignedIdentityResourceId"] = identity + elif auth_type == 'Basic': + if not username_setting or not password_setting: + raise RequiredArgumentMissingError('--deployment-image-username-setting and ' + '--deployment-image-password-setting are required with ' + '--deployment-image-auth-type Basic.') + authentication["usernameSettingName"] = username_setting + authentication["passwordSettingName"] = password_setting + if server_url is not None: + authentication["serverUrl"] = server_url + return authentication + + +def _build_flex_registry_function_app_config(image, authentication, instance_memory, maximum_instance_count, + always_ready_instances): + return { + "deployment": { + "storage": { + "type": "Registry", + "value": image, + "authentication": authentication + } + }, + "scaleAndConcurrency": { + "maximumInstanceCount": maximum_instance_count or FLEX_DEFAULT_MAXIMUM_INSTANCE_COUNT, + "instanceMemoryMB": instance_memory or FLEX_DEFAULT_INSTANCE_MEMORY_MB, + "alwaysReady": _build_flex_always_ready_config(always_ready_instances) + } + } + + def revert_flex_migration(cmd, source_resource_group, source_name): site = get_raw_functionapp(cmd.cli_ctx, source_resource_group, source_name) sku = site.get('properties', {}).get('sku') @@ -4152,7 +4214,7 @@ def _get_linux_multicontainer_encoded_config_from_file(file_name): def get_deployment_configs(cmd, resource_group_name, name): - functionapp = get_raw_functionapp(cmd.cli_ctx, resource_group_name, name) + functionapp = get_raw_functionapp(cmd.cli_ctx, resource_group_name, name, api_version=FLEX_REGISTRY_API_VERSION) return functionapp.get("properties", {}).get("functionAppConfig", {}).get( "deployment", {}) @@ -4160,7 +4222,21 @@ def get_deployment_configs(cmd, resource_group_name, name): def update_deployment_configs(cmd, resource_group_name, name, # pylint: disable=too-many-branches deployment_storage_name=None, deployment_storage_container_name=None, deployment_storage_auth_type=None, - deployment_storage_auth_value=None): + deployment_storage_auth_value=None, deployment_image=None, + deployment_image_auth_type=None, deployment_image_identity=None, + deployment_image_username_setting=None, deployment_image_password_setting=None, + deployment_image_server_url=None): + + registry_authentication = _build_flex_registry_authentication( + deployment_image_auth_type, deployment_image_identity, deployment_image_username_setting, + deployment_image_password_setting, deployment_image_server_url) + if deployment_image is not None or registry_authentication is not None: + if any(arg is not None for arg in (deployment_storage_name, deployment_storage_container_name, + deployment_storage_auth_type, deployment_storage_auth_value)): + raise MutuallyExclusiveArgumentError('--deployment-image arguments cannot be used with ' + '--deployment-storage arguments.') + return _update_flex_registry_deployment_config(cmd, resource_group_name, name, deployment_image, + registry_authentication) if (deployment_storage_name is not None) != (deployment_storage_container_name is not None): raise ArgumentUsageError("Please provide both --deployment-storage-name and " @@ -4181,6 +4257,9 @@ def update_deployment_configs(cmd, resource_group_name, name, # pylint: disable functionapp = get_raw_functionapp(cmd.cli_ctx, resource_group_name, name) functionapp_deployment_storage = functionapp["properties"]["functionAppConfig"]["deployment"]["storage"] + if _is_flex_registry_storage(functionapp_deployment_storage): + raise ValidationError('This function app uses Registry deployment storage. Use the --deployment-image ' + 'arguments to update it.') deployment_storage = None @@ -4256,6 +4335,28 @@ def update_deployment_configs(cmd, resource_group_name, name, # pylint: disable return result.get("properties", {}).get("functionAppConfig", {}).get("deployment", {}) +def _update_flex_registry_deployment_config(cmd, resource_group_name, name, image, authentication): + functionapp = get_raw_functionapp(cmd.cli_ctx, resource_group_name, name, api_version=FLEX_REGISTRY_API_VERSION) + function_app_config = functionapp["properties"]["functionAppConfig"] + storage = function_app_config["deployment"]["storage"] + if not _is_flex_registry_storage(storage) and (not image or authentication is None): + raise RequiredArgumentMissingError('--deployment-image and --deployment-image-auth-type are required to ' + 'switch to Registry deployment storage.') + + storage["type"] = "Registry" + if image is not None: + storage["value"] = image + if authentication is None: + # Keep the current mode; GET returns the fields of other modes as null. + authentication = {key: value for key, value in storage["authentication"].items() if value is not None} + storage["authentication"] = authentication + function_app_config.pop("runtime", None) + + result = update_flex_functionapp(cmd, resource_group_name, name, functionapp, + api_version=FLEX_REGISTRY_API_VERSION) + return result.get("properties", {}).get("functionAppConfig", {}).get("deployment", {}) + + # for any modifications to the non-optional parameters, adjust the reflection logic accordingly # in the method # pylint: disable=unused-argument @@ -4457,11 +4558,11 @@ def update_configuration_polling(cmd, resource_group_name, name, slot, configs): raise CLIError(ex) -def update_flex_functionapp(cmd, resource_group_name, name, functionapp): +def update_flex_functionapp(cmd, resource_group_name, name, functionapp, api_version='2023-12-01'): from azure.cli.core.commands.client_factory import get_subscription_id subscription_id = get_subscription_id(cmd.cli_ctx) url_base = 'subscriptions/{}/resourceGroups/{}/providers/Microsoft.Web/sites/{}?api-version={}' - url = url_base.format(subscription_id, resource_group_name, name, '2023-12-01') + url = url_base.format(subscription_id, resource_group_name, name, api_version) request_url = cmd.cli_ctx.cloud.endpoints.resource_manager + url body = json.dumps(functionapp) response = send_raw_request(cmd.cli_ctx, "PUT", request_url, body=body) @@ -10063,7 +10164,10 @@ def create_functionapp(cmd, resource_group_name, name, storage_account, plan=Non flexconsumption_location=None, deployment_storage_name=None, deployment_storage_container_name=None, deployment_storage_auth_type=None, deployment_storage_auth_value=None, zone_redundant=False, configure_networking_later=None, - auto_generated_domain_name_label_scope=None): + auto_generated_domain_name_label_scope=None, deployment_image=None, + deployment_image_auth_type=None, deployment_image_identity=None, + deployment_image_username_setting=None, deployment_image_password_setting=None, + deployment_image_server_url=None): # pylint: disable=too-many-statements, too-many-branches if functions_version is None and flexconsumption_location is None: @@ -10150,6 +10254,26 @@ def create_functionapp(cmd, resource_group_name, name, storage_account, plan=Non "provide the name of the flex plan location using " "--flexconsumption-location.") + registry_authentication = _build_flex_registry_authentication( + deployment_image_auth_type, deployment_image_identity, deployment_image_username_setting, + deployment_image_password_setting, deployment_image_server_url) + is_flex_registry = deployment_image is not None or registry_authentication is not None + if is_flex_registry: + if flexconsumption_location is None: + raise RequiredArgumentMissingError('--deployment-image arguments must be used with ' + '--flexconsumption-location.') + if not deployment_image or registry_authentication is None: + raise RequiredArgumentMissingError('--deployment-image and --deployment-image-auth-type are both required ' + 'to use Registry deployment storage.') + if any(arg is not None for arg in (runtime, runtime_version, environment, deployment_storage_name, + deployment_storage_container_name, deployment_storage_auth_type, + deployment_storage_auth_value, registry_server, registry_username, + registry_password)): + raise MutuallyExclusiveArgumentError( + '--deployment-image cannot be used with --runtime, --runtime-version, --environment, ' + '--deployment-storage-* or --registry-* arguments. For registry credentials, use ' + '--deployment-image-auth-type Basic with the names of the app settings that store them.') + if flexconsumption_location is None: deployment_source_branch = deployment_source_branch or 'master' @@ -10285,14 +10409,16 @@ def create_functionapp(cmd, resource_group_name, name, storage_account, plan=Non else: docker_registry_server_url = parse_docker_image_name(image, environment) - if is_linux and not runtime and (consumption_plan_location or not image): + if is_linux and not runtime and not is_flex_registry and (consumption_plan_location or not image): raise ArgumentUsageError( "usage error: --runtime RUNTIME required for linux functions apps without custom image.") if runtime is None and runtime_version is not None: raise ArgumentUsageError('Must specify --runtime to use --runtime-version') - if flexconsumption_location: + if is_flex_registry: + matched_runtime = None + elif flexconsumption_location: runtime_helper = _FlexFunctionAppStackRuntimeHelper(cmd, flexconsumption_location, runtime, runtime_version) matched_runtime = runtime_helper.resolve(runtime, runtime_version) else: @@ -10477,7 +10603,7 @@ def create_functionapp(cmd, resource_group_name, name, storage_account, plan=Non elif flexconsumption_location is None and (disable_app_insights or not matched_runtime.app_insights): # set up dashboard if no app insights site_config.app_settings.append(NameValuePair(name='AzureWebJobsDashboard', value=con_string)) - elif not disable_app_insights and matched_runtime.app_insights: + elif not disable_app_insights and (is_flex_registry or matched_runtime.app_insights): create_app_insights = True if flexconsumption_location is not None: @@ -10513,20 +10639,26 @@ def create_functionapp(cmd, resource_group_name, name, storage_account, plan=Non 'StorageAccountConnectionString.' ) - flex_sku = matched_runtime.sku - flex_storage_setup = _prepare_flex_deployment_storage( - cmd, resource_group_name, name, deployment_storage_name, deployment_storage_container_name, - deployment_storage_auth_type, deployment_storage_auth_value, flexconsumption_location, flex_sku, - instance_memory, maximum_instance_count, always_ready_instances) - deployment_storage_auth_value = flex_storage_setup['deployment_storage_auth_value'] - for setting in flex_storage_setup['app_settings_to_add']: - site_config.app_settings.append(NameValuePair(name=setting['name'], value=setting['value'])) + if is_flex_registry: + function_app_config = _build_flex_registry_function_app_config( + deployment_image, registry_authentication, instance_memory, maximum_instance_count, + always_ready_instances) + else: + flex_sku = matched_runtime.sku + flex_storage_setup = _prepare_flex_deployment_storage( + cmd, resource_group_name, name, deployment_storage_name, deployment_storage_container_name, + deployment_storage_auth_type, deployment_storage_auth_value, flexconsumption_location, flex_sku, + instance_memory, maximum_instance_count, always_ready_instances) + deployment_storage_auth_value = flex_storage_setup['deployment_storage_auth_value'] + for setting in flex_storage_setup['app_settings_to_add']: + site_config.app_settings.append(NameValuePair(name=setting['name'], value=setting['value'])) + function_app_config = flex_storage_setup['function_app_config'] # Set flex consumption properties on the site from azure.mgmt.web.models import SiteProperties if functionapp_def.properties is None: functionapp_def.properties = SiteProperties() - functionapp_def.properties.function_app_config = flex_storage_setup['function_app_config'] + functionapp_def.properties.function_app_config = function_app_config functionapp_def.properties.sku = "FlexConsumption" # Use a client with specific API version for flex consumption flex_client = web_client_factory(cmd.cli_ctx, api_version='2025-05-01') @@ -10573,7 +10705,7 @@ def create_functionapp(cmd, resource_group_name, name, storage_account, plan=Non image, registry_username, registry_password) - if flexconsumption_location is not None: + if flexconsumption_location is not None and not is_flex_registry: _prepare_flex_deployment_storage_identity( cmd, resource_group_name, name, flex_storage_setup) @@ -10582,6 +10714,8 @@ def create_functionapp(cmd, resource_group_name, name, storage_account, plan=Non role, None, scope) functionapp.identity = identity + if is_flex_registry: + return get_raw_functionapp(cmd.cli_ctx, resource_group_name, name, api_version=FLEX_REGISTRY_API_VERSION) if flexconsumption_location is not None: return get_raw_functionapp(cmd.cli_ctx, resource_group_name, 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 359bd4a7874..65f2e188ed8 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 @@ -1768,6 +1768,65 @@ def test_functionapp_flex_deployment_config_by_user_identity_id(self, functionap self.assertTrue(deployment_config['storage']['authentication']['storageAccountConnectionStringName'] is None) + @ResourceGroupPreparer(location=FLEX_ASP_LOCATION_FUNCTIONAPP) + @StorageAccountPreparer() + def test_functionapp_flex_registry_deployment(self, resource_group, storage_account): + # Requires Registry deployment storage to be enabled in the region. The service only stores the + # configuration; it doesn't pull the images during these commands. + image = 'mcr.microsoft.com/azure-functions/dotnet-isolated:4-dotnet-isolated8.0' + digest_image = 'mcr.microsoft.com/azure-functions/dotnet-isolated@sha256:' + 'a' * 64 + storage = 'properties.functionAppConfig.deployment.storage' + identity = self.cmd('identity create -g {} -n {}'.format( + resource_group, self.create_random_name('id', 8))).get_output_in_json() + + uai_app = self.create_random_name('functionapp', 40) + self.cmd('functionapp create -g {} -n {} -f {} -s {} --deployment-image {} ' + '--deployment-image-auth-type UserAssignedIdentity --deployment-image-identity {} --assign-identity {}' + .format(resource_group, uai_app, FLEX_ASP_LOCATION_FUNCTIONAPP, storage_account, image, + identity['id'], identity['id']), checks=[ + JMESPathCheck('properties.functionAppConfig.runtime', None), + JMESPathCheck(storage + '.type', 'Registry'), + JMESPathCheck(storage + '.value', image), + JMESPathCheck(storage + '.authentication.type', 'UserAssignedIdentity'), + JMESPathCheck(storage + '.authentication.userAssignedIdentityResourceId', identity['id'])]) + self.cmd('functionapp deployment config show -g {} -n {}'.format(resource_group, uai_app), checks=[ + JMESPathCheck('storage.type', 'Registry'), + JMESPathCheck('storage.value', image)]) + self.cmd('functionapp deployment config set -g {} -n {} --deployment-image-auth-type Basic ' + '--deployment-image-username-setting REGISTRY_USERNAME --deployment-image-password-setting REGISTRY_PASSWORD ' + '--deployment-image-server-url https://mcr.microsoft.com'.format(resource_group, uai_app), checks=[ + JMESPathCheck('storage.value', image), + JMESPathCheck('storage.authentication.type', 'Basic'), + JMESPathCheck('storage.authentication.usernameSettingName', 'REGISTRY_USERNAME'), + JMESPathCheck('storage.authentication.passwordSettingName', 'REGISTRY_PASSWORD'), + JMESPathCheck('storage.authentication.serverUrl', 'https://mcr.microsoft.com'), + JMESPathCheck('storage.authentication.userAssignedIdentityResourceId', None)]) + + basic_app = self.create_random_name('functionapp', 40) + self.cmd('functionapp create -g {} -n {} -f {} -s {} --deployment-image {} --deployment-image-auth-type Basic ' + '--deployment-image-username-setting REGISTRY_USERNAME --deployment-image-password-setting REGISTRY_PASSWORD ' + '--deployment-image-server-url https://mcr.microsoft.com' + .format(resource_group, basic_app, FLEX_ASP_LOCATION_FUNCTIONAPP, storage_account, image), checks=[ + JMESPathCheck('properties.functionAppConfig.runtime', None), + JMESPathCheck(storage + '.authentication.type', 'Basic'), + JMESPathCheck(storage + '.authentication.passwordSettingName', 'REGISTRY_PASSWORD'), + JMESPathCheck(storage + '.authentication.serverUrl', 'https://mcr.microsoft.com')]) + + blob_app = self.create_random_name('functionapp', 40) + self.cmd('functionapp create -g {} -n {} -f {} -s {} --runtime python --runtime-version 3.11' + .format(resource_group, blob_app, FLEX_ASP_LOCATION_FUNCTIONAPP, storage_account)) + self.cmd('functionapp deployment config set -g {} -n {} --deployment-image {} ' + '--deployment-image-auth-type UserAssignedIdentity --deployment-image-identity {}' + .format(resource_group, blob_app, digest_image, identity['id']), checks=[ + JMESPathCheck('storage.type', 'Registry'), + JMESPathCheck('storage.value', digest_image), + JMESPathCheck('storage.authentication.userAssignedIdentityResourceId', identity['id']), + JMESPathCheck('storage.authentication.storageAccountConnectionStringName', None)]) + self.cmd('resource show -g {} -n {} --resource-type Microsoft.Web/sites --api-version 2025-05-01' + .format(resource_group, blob_app), checks=[ + JMESPathCheck(storage + '.type', 'Registry'), + JMESPathCheck('properties.functionAppConfig.runtime', None)]) + @ResourceGroupPreparer(location=FLEX_ASP_LOCATION_FUNCTIONAPP) @StorageAccountPreparer() def test_functionapp_flex_vnet_integration(self, resource_group, storage_account): 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 27f44906218..52dd16b8a16 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 @@ -2,8 +2,12 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +import io +import json import unittest +from contextlib import contextmanager from unittest import mock +from urllib.parse import parse_qs, urlparse import os from azure.mgmt.web import WebSiteManagementClient @@ -24,9 +28,12 @@ _build_flex_function_app_config, _prepare_flex_deployment_storage, _prepare_flex_deployment_storage_identity, - revert_flex_migration) + revert_flex_migration, + create_functionapp, + get_deployment_configs, + update_deployment_configs) from azure.cli.core.profiles import ResourceType -from azure.cli.core.azclierror import (AzureInternalError, UnclassifiedUserFault) +from azure.cli.core.azclierror import (AzureInternalError, UnclassifiedUserFault, HTTPError) from azure.cli.core.azclierror import (ResourceNotFoundError, MutuallyExclusiveArgumentError, RequiredArgumentMissingError, ValidationError, ArgumentUsageError) @@ -1437,3 +1444,360 @@ def test_revert_submits_dynamic_sku(self, get_raw_functionapp_mock, web_client_f long_running_operation_mock.assert_called_once_with(cmd_mock.cli_ctx) long_running_operation_mock.return_value.assert_called_once_with(poller_mock) get_functionapp_mock.assert_called_once_with(cmd_mock, 'src-rg', 'src-app') + + +_REGISTRY_SUBSCRIPTION = '00000000-0000-0000-0000-000000000000' +_REGISTRY_SITE = 'subscriptions/{}/resourceGroups/rg/providers/Microsoft.Web/sites/app'.format(_REGISTRY_SUBSCRIPTION) +_REGISTRY_IDENTITY = ('/subscriptions/{}/resourceGroups/rg/providers/Microsoft.ManagedIdentity/' + 'userAssignedIdentities/acr-pull'.format(_REGISTRY_SUBSCRIPTION)) +_REGISTRY_DIGEST = 'sha256:' + 'a' * 64 +_REGISTRY_SECRET = 'SENTINEL-REGISTRY-PASSWORD-2f9c' + + +def _flex_blob_site(): + return { + 'id': '/' + _REGISTRY_SITE, + 'name': 'app', + 'kind': 'functionapp,linux', + 'location': 'East US', + 'tags': {'team': 'functions'}, + 'properties': { + 'sku': 'FlexConsumption', + 'unknownSiteProperty': {'keep': True}, + 'functionAppConfig': { + 'deployment': { + 'storage': { + 'type': 'blobContainer', + 'value': 'https://sa.blob.core.windows.net/app-package-app', + 'authentication': { + 'type': 'StorageAccountConnectionString', + 'userAssignedIdentityResourceId': None, + 'storageAccountConnectionStringName': 'DEPLOYMENT_STORAGE_CONNECTION_STRING' + } + } + }, + 'runtime': {'name': 'python', 'version': '3.11'}, + 'scaleAndConcurrency': { + 'alwaysReady': [{'name': 'http', 'instanceCount': 1}], + 'maximumInstanceCount': 40, + 'instanceMemoryMB': 4096, + 'triggers': {'http': {'perInstanceConcurrency': 8}} + }, + 'siteUpdateStrategy': {'type': 'RollingUpdate'}, + 'unknownFunctionAppConfigProperty': {'keep': True} + } + } + } + + +def _flex_registry_site(): + # Shape returned by the service: runtime is null and the fields of other authentication modes are null. + site = _flex_blob_site() + function_app_config = site['properties']['functionAppConfig'] + function_app_config['runtime'] = None + function_app_config['deployment']['storage'] = { + 'type': 'Registry', + 'value': 'myacr.azurecr.io/app:v1', + 'authentication': { + 'type': 'UserAssignedIdentity', + 'userAssignedIdentityResourceId': _REGISTRY_IDENTITY, + 'storageAccountConnectionStringName': None, + 'usernameSettingName': None, + 'passwordSettingName': None, + 'serverUrl': None + } + } + return site + + +def _with_registry_storage(site, storage): + function_app_config = site['properties']['functionAppConfig'] + del function_app_config['runtime'] + function_app_config['deployment']['storage'] = storage + return site + + +class _FakeArmSite: + """Stands in for ARM behind requests.Session.send, so the real send_raw_request builds, sends and logs requests. + + Stores one site, echoes accepted PUT bodies, and serves a sentinel secret from the app settings endpoint. + """ + + def __init__(self, site, put_error=None): + self.site = site + self.put_error = put_error + self.requests = [] + + def send(self, _session, request, **_kwargs): + import requests + body = json.loads(request.body) if request.body else None + self.requests.append((request.method, request.url, body)) + status, payload = 200, self.site + if '/config/appsettings/list' in request.url: + payload = {'properties': {'REGISTRY_PASSWORD': _REGISTRY_SECRET}} + elif request.method == 'PUT': + if self.put_error: + status, payload = 400, self.put_error + else: + self.site = payload = body + response = requests.Response() + response.status_code = status + response.reason = 'OK' if status == 200 else 'Bad Request' + response.headers['Content-Type'] = 'application/json' + response._content = json.dumps(payload).encode() # pylint: disable=protected-access + response.url = request.url + return response + + def calls(self): + return [(method, urlparse(url).path.lstrip('/'), parse_qs(urlparse(url).query)['api-version'][0]) + for method, url, _ in self.requests] + + +@contextmanager +def _fake_arm(site, put_error=None): + arm = _FakeArmSite(site, put_error) + with mock.patch('requests.Session.send', autospec=True, side_effect=arm.send), \ + mock.patch('azure.cli.core._profile.Profile.get_raw_token', + return_value=(('Bearer', 'token', None), _REGISTRY_SUBSCRIPTION, 'tenant')), \ + mock.patch('azure.cli.command_modules.appservice.utils.get_subscription_id', + return_value=_REGISTRY_SUBSCRIPTION), \ + mock.patch('azure.cli.core.commands.client_factory.get_subscription_id', + return_value=_REGISTRY_SUBSCRIPTION): + yield arm + + +class TestFlexRegistryDeploymentConfigMocked(unittest.TestCase): + """`functionapp deployment config set/show` with Registry storage, asserted on the HTTP requests sent to ARM.""" + + def setUp(self): + self.cmd = _get_test_cmd() + + def test_set_switches_blob_app_to_registry_with_exact_payload(self): + basic_args = {'deployment_image_auth_type': 'Basic', 'deployment_image_username_setting': 'REGISTRY_USERNAME', + 'deployment_image_password_setting': 'REGISTRY_PASSWORD'} + basic_auth = {'type': 'Basic', 'usernameSettingName': 'REGISTRY_USERNAME', + 'passwordSettingName': 'REGISTRY_PASSWORD'} + cases = [ + ('Anonymous, tag only', 'mcr.microsoft.com/azure-functions/dotnet-isolated:4-dotnet-isolated8.0', + {'deployment_image_auth_type': 'Anonymous'}, {'type': 'Anonymous'}), + ('SystemAssignedIdentity, digest only', 'myacr.azurecr.io/app@' + _REGISTRY_DIGEST, + {'deployment_image_auth_type': 'SystemAssignedIdentity'}, {'type': 'SystemAssignedIdentity'}), + ('UserAssignedIdentity, tag and digest', 'myacr.azurecr.io/app:v1@' + _REGISTRY_DIGEST, + {'deployment_image_auth_type': 'UserAssignedIdentity', 'deployment_image_identity': _REGISTRY_IDENTITY}, + {'type': 'UserAssignedIdentity', 'userAssignedIdentityResourceId': _REGISTRY_IDENTITY}), + ('Basic', 'registry.contoso.com:5000/team/app:v1', basic_args, basic_auth), + ('Basic with server URL', 'registry.contoso.com:5000/team/app:v1', + dict(basic_args, deployment_image_server_url='https://registry.contoso.com:5000'), + dict(basic_auth, serverUrl='https://registry.contoso.com:5000')), + ] + for case, image, auth_args, authentication in cases: + with self.subTest(case): + storage = {'type': 'Registry', 'value': image, 'authentication': authentication} + with _fake_arm(_flex_blob_site()) as arm: + result = update_deployment_configs(self.cmd, 'rg', 'app', deployment_image=image, **auth_args) + shown = get_deployment_configs(self.cmd, 'rg', 'app') + + self.assertEqual(arm.calls(), [('GET', _REGISTRY_SITE, '2025-05-01'), + ('PUT', _REGISTRY_SITE, '2025-05-01'), + ('GET', _REGISTRY_SITE, '2025-05-01')]) + self.assertEqual(arm.requests[1][2], _with_registry_storage(_flex_blob_site(), storage)) + self.assertEqual(result, {'storage': storage}) + self.assertEqual(shown, {'storage': storage}) + + def test_set_on_registry_app_keeps_the_unspecified_image_or_authentication(self): + cases = [ + ('image only', {'deployment_image': 'myacr.azurecr.io/app:v2'}, + {'type': 'Registry', 'value': 'myacr.azurecr.io/app:v2', + 'authentication': {'type': 'UserAssignedIdentity', + 'userAssignedIdentityResourceId': _REGISTRY_IDENTITY}}), + ('authentication only', {'deployment_image_auth_type': 'SystemAssignedIdentity'}, + {'type': 'Registry', 'value': 'myacr.azurecr.io/app:v1', + 'authentication': {'type': 'SystemAssignedIdentity'}}), + ] + for case, args, storage in cases: + with self.subTest(case): + with _fake_arm(_flex_registry_site()) as arm: + update_deployment_configs(self.cmd, 'rg', 'app', **args) + + self.assertEqual([method for method, _, _ in arm.requests], ['GET', 'PUT']) + self.assertEqual(arm.requests[1][2], _with_registry_storage(_flex_registry_site(), storage)) + + def test_set_rejects_invalid_registry_arguments_without_writing(self): + image_args = {'deployment_image': 'myacr.azurecr.io/app:v1', 'deployment_image_auth_type': 'Anonymous'} + basic_args = {'deployment_image_auth_type': 'Basic', 'deployment_image_username_setting': 'REGISTRY_USERNAME', + 'deployment_image_password_setting': 'REGISTRY_PASSWORD'} + cases = [ + ('authentication argument without type', _flex_registry_site, + {'deployment_image_identity': _REGISTRY_IDENTITY}, RequiredArgumentMissingError), + ('empty user-assigned identity', _flex_registry_site, + {'deployment_image_auth_type': 'UserAssignedIdentity', 'deployment_image_identity': ''}, + RequiredArgumentMissingError), + ('Basic without password setting', _flex_registry_site, + {'deployment_image_auth_type': 'Basic', 'deployment_image_username_setting': 'REGISTRY_USERNAME'}, + RequiredArgumentMissingError), + ('identity with Basic', _flex_registry_site, + dict(basic_args, deployment_image_identity=_REGISTRY_IDENTITY), ArgumentUsageError), + ('server URL without Basic', _flex_registry_site, + dict(image_args, deployment_image_server_url='https://myacr.azurecr.io'), ArgumentUsageError), + ('Registry and blob arguments together', _flex_blob_site, + dict(image_args, deployment_storage_auth_type='SystemAssignedIdentity'), MutuallyExclusiveArgumentError), + ('switch from blob without authentication', _flex_blob_site, + {'deployment_image': 'myacr.azurecr.io/app:v1'}, RequiredArgumentMissingError), + ('switch from blob without image', _flex_blob_site, + {'deployment_image_auth_type': 'Anonymous'}, RequiredArgumentMissingError), + ('blob arguments on a Registry app', _flex_registry_site, + {'deployment_storage_auth_type': 'SystemAssignedIdentity'}, ValidationError), + ] + for case, site, args, error in cases: + with self.subTest(case): + with _fake_arm(site()) as arm, self.assertRaises(error): + update_deployment_configs(self.cmd, 'rg', 'app', **args) + self.assertNotIn('PUT', [method for method, _, _ in arm.requests]) + + def test_set_surfaces_field_scoped_service_rejection_after_a_single_write(self): + # The CLI doesn't parse image references; the service rejects invalid ones with a field-scoped error. + field_error = {'Code': 'BadRequest', 'Message': 'The parameter Site.FunctionAppConfig.Deployment.Storage.Value ' + 'has an invalid value.'} + with _fake_arm(_flex_blob_site(), put_error=field_error) as arm, self.assertRaises(HTTPError) as error: + update_deployment_configs(self.cmd, 'rg', 'app', deployment_image='myacr.azurecr.io/App:Latest!', + deployment_image_auth_type='Anonymous') + + self.assertIn('Site.FunctionAppConfig.Deployment.Storage.Value', str(error.exception)) + self.assertEqual([method for method, _, _ in arm.requests], ['GET', 'PUT']) + self.assertEqual(arm.requests[1][2]['properties']['functionAppConfig']['deployment']['storage']['value'], + 'myacr.azurecr.io/App:Latest!') + + def test_set_debug_log_shows_setting_names_but_never_registry_secrets(self): + with _fake_arm(_flex_blob_site()) as arm, \ + self.assertLogs('cli.azure.cli.core.util', level='DEBUG') as logs: + result = update_deployment_configs( + self.cmd, 'rg', 'app', deployment_image='myacr.azurecr.io/app:v1', deployment_image_auth_type='Basic', + deployment_image_username_setting='REGISTRY_USERNAME', + deployment_image_password_setting='REGISTRY_PASSWORD') + + debug_log = '\n'.join(logs.output) + self.assertIn('"passwordSettingName": "REGISTRY_PASSWORD"', debug_log) + self.assertNotIn(_REGISTRY_SECRET, debug_log + json.dumps(result)) + self.assertEqual([method for method, _, _ in arm.requests], ['GET', 'PUT']) + + +class TestFlexRegistryCreateMocked(unittest.TestCase): + """`functionapp create --deployment-image` builds a Registry functionAppConfig without a runtime.""" + + def setUp(self): + self.cmd = _get_test_cmd() + self.web_client_factory = self._patch('web_client_factory') + self._patch('list_flexconsumption_locations', return_value=[{'name': 'eastus'}]) + self._patch('is_storage_account_network_restricted', return_value=False) + self._patch('_validate_and_get_connection_string', return_value='storage-connection-string') + self._patch('create_flex_app_service_plan', return_value=mock.Mock(id='plan-id')) + self._patch('LongRunningOperation') + self._patch('_set_remote_or_local_git') + self.create_app_insights = self._patch('try_create_workspace_based_application_insights') + self.assign_identity = self._patch('assign_identity') + self.get_raw_functionapp = self._patch('get_raw_functionapp') + self.runtime_helper = self._patch('_FlexFunctionAppStackRuntimeHelper') + self.prepare_storage = self._patch('_prepare_flex_deployment_storage') + self.prepare_storage_identity = self._patch('_prepare_flex_deployment_storage_identity') + + def _patch(self, name, **kwargs): + patcher = mock.patch('azure.cli.command_modules.appservice.custom.' + name, **kwargs) + self.addCleanup(patcher.stop) + return patcher.start() + + def _created_site(self): + self.web_client_factory.assert_called_with(self.cmd.cli_ctx, api_version='2025-05-01') + return self.web_client_factory.return_value.web_apps.begin_create_or_update.call_args.args[2].as_dict() + + def test_create_sends_registry_config_without_runtime_and_with_default_scale(self): + acr = ('/subscriptions/{}/resourceGroups/rg/providers/Microsoft.ContainerRegistry/registries/myacr' + .format(_REGISTRY_SUBSCRIPTION)) + image = 'myacr.azurecr.io/app@' + _REGISTRY_DIGEST + + result = create_functionapp(self.cmd, 'rg', 'app', 'sa', flexconsumption_location='eastus', + deployment_image=image, deployment_image_auth_type='SystemAssignedIdentity', + assign_identities=['[system]'], role='AcrPull', scope=acr) + + site = self._created_site() + self.assertEqual(site['properties']['functionAppConfig'], { + 'deployment': {'storage': {'type': 'Registry', 'value': image, + 'authentication': {'type': 'SystemAssignedIdentity'}}}, + 'scaleAndConcurrency': {'maximumInstanceCount': 100, 'instanceMemoryMB': 2048, 'alwaysReady': []} + }) + self.assertEqual([setting['name'] for setting in site['properties']['siteConfig']['appSettings']], + ['AzureWebJobsStorage']) + self.runtime_helper.assert_not_called() + self.prepare_storage.assert_not_called() + self.prepare_storage_identity.assert_not_called() + self.create_app_insights.assert_called_once() + self.assign_identity.assert_called_once_with(self.cmd, 'rg', 'app', ['[system]'], 'AcrPull', None, acr) + self.get_raw_functionapp.assert_called_once_with(self.cmd.cli_ctx, 'rg', 'app', api_version='2025-05-01') + self.assertIs(result, self.get_raw_functionapp.return_value) + + def test_create_with_basic_authentication_uses_explicit_scale_settings(self): + create_functionapp(self.cmd, 'rg', 'app', 'sa', flexconsumption_location='eastus', + deployment_image='registry.contoso.com/team/app:v1', deployment_image_auth_type='Basic', + deployment_image_username_setting='REGISTRY_USERNAME', + deployment_image_password_setting='REGISTRY_PASSWORD', + deployment_image_server_url='https://registry.contoso.com', + instance_memory=4096, maximum_instance_count=40, always_ready_instances=['http=2'], + disable_app_insights='true') + + self.assertEqual(self._created_site()['properties']['functionAppConfig'], { + 'deployment': {'storage': {'type': 'Registry', 'value': 'registry.contoso.com/team/app:v1', + 'authentication': {'type': 'Basic', 'usernameSettingName': 'REGISTRY_USERNAME', + 'passwordSettingName': 'REGISTRY_PASSWORD', + 'serverUrl': 'https://registry.contoso.com'}}}, + 'scaleAndConcurrency': {'maximumInstanceCount': 40, 'instanceMemoryMB': 4096, + 'alwaysReady': [{'name': 'http', 'instanceCount': 2}]} + }) + self.create_app_insights.assert_not_called() + + def test_create_rejects_conflicting_registry_arguments_before_calling_azure(self): + registry_args = {'flexconsumption_location': 'eastus', 'deployment_image': 'myacr.azurecr.io/app:v1', + 'deployment_image_auth_type': 'Anonymous'} + cases = [ + ('runtime', {'runtime': 'python'}, MutuallyExclusiveArgumentError), + ('blob deployment storage', {'deployment_storage_name': 'sa2'}, MutuallyExclusiveArgumentError), + ('legacy registry credentials', {'registry_password': 'password'}, MutuallyExclusiveArgumentError), + ('container app environment', {'environment': 'env'}, MutuallyExclusiveArgumentError), + ('missing authentication type', {'deployment_image_auth_type': None}, RequiredArgumentMissingError), + ('missing image', {'deployment_image': None}, RequiredArgumentMissingError), + ('not Flex Consumption', {'flexconsumption_location': None, 'plan': 'plan'}, RequiredArgumentMissingError), + ] + for case, overrides, error in cases: + with self.subTest(case), self.assertRaises(error): + create_functionapp(self.cmd, 'rg', 'app', 'sa', **dict(registry_args, **overrides)) + self.web_client_factory.assert_not_called() + + +class TestFlexRegistryArgumentParsing(unittest.TestCase): + + def test_registry_arguments_and_aliases_reach_both_commands(self): + from azure.cli.core.mock import DummyCli + no_auth_fields = {'deployment_image_identity': None, 'deployment_image_username_setting': None, + 'deployment_image_password_setting': None, 'deployment_image_server_url': None} + cases = [ + ('create_functionapp', + ['functionapp', 'create', '-g', 'rg', '-n', 'app', '-s', 'sa', '--flexconsumption-location', 'eastus', + '--deployment-image', 'myacr.azurecr.io/app:v1@' + _REGISTRY_DIGEST, + '--diat', 'userassignedidentity', '--dii', _REGISTRY_IDENTITY], + dict(no_auth_fields, deployment_image='myacr.azurecr.io/app:v1@' + _REGISTRY_DIGEST, + deployment_image_auth_type='UserAssignedIdentity', deployment_image_identity=_REGISTRY_IDENTITY)), + ('update_deployment_configs', + ['functionapp', 'deployment', 'config', 'set', '-g', 'rg', '-n', 'app', + '--deployment-image', 'myacr.azurecr.io/app:v1', '--diat', 'basic', '--dius', 'REGISTRY_USERNAME', + '--dips', 'REGISTRY_PASSWORD', '--diurl', 'https://myacr.azurecr.io'], + dict(no_auth_fields, deployment_image='myacr.azurecr.io/app:v1', deployment_image_auth_type='Basic', + deployment_image_username_setting='REGISTRY_USERNAME', + deployment_image_password_setting='REGISTRY_PASSWORD', + deployment_image_server_url='https://myacr.azurecr.io')), + ] + for handler, args, expected in cases: + with self.subTest(handler), \ + mock.patch('azure.cli.command_modules.appservice.custom.' + handler, autospec=True, + return_value={}) as handler_mock, \ + mock.patch('azure.cli.command_modules.appservice.commands.validate_is_flex_functionapp'): + self.assertEqual(DummyCli().invoke(args, out_file=io.StringIO()), 0) + received = handler_mock.call_args.kwargs + self.assertEqual({key: received[key] for key in expected}, expected) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/utils.py b/src/azure-cli/azure/cli/command_modules/appservice/utils.py index 8f22eb008ca..be1557e1655 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/utils.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/utils.py @@ -261,10 +261,10 @@ def _rename_server_farm_props(webapp): return webapp -def get_raw_functionapp(cli_ctx, resource_group_name, name): +def get_raw_functionapp(cli_ctx, resource_group_name, name, api_version='2023-12-01'): site_url_base = '/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Web/sites/{}?api-version={}' subscription_id = get_subscription_id(cli_ctx) - site_url = site_url_base.format(subscription_id, resource_group_name, name, '2023-12-01') + site_url = site_url_base.format(subscription_id, resource_group_name, name, api_version) request_url = cli_ctx.cloud.endpoints.resource_manager + site_url response = send_raw_request(cli_ctx, "GET", request_url) return response.json() From 713b3a75d6b2a98631d90be8a2de830d807fe0e0 Mon Sep 17 00:00:00 2001 From: PragatiKushwaha Date: Tue, 29 Sep 2026 15:40:04 +0530 Subject: [PATCH 2/5] [App Service] `az functionapp show`, `az functionapp deployment config show`: Return Flex Registry configuration with API version 2025-05-01 - Re-read Flex apps that use Registry deployment storage at 2025-05-01 so both show commands return the persisted Registry configuration. Blob storage apps keep their existing request and output. - Tests: show round-trips for both show commands, a rejected update followed by show, secret-safe set/show output, and legacy container markers never set on create. The live scenario covers the same flows end to end. - Help: describe what deployment config show returns for Registry storage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../cli/command_modules/appservice/_help.py | 6 +- .../cli/command_modules/appservice/custom.py | 13 +++- .../tests/latest/test_functionapp_commands.py | 21 +++++- .../test_functionapp_commands_thru_mock.py | 75 ++++++++++++++----- 4 files changed, 93 insertions(+), 22 deletions(-) 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 30eef8e3122..63571d3638a 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -622,6 +622,10 @@ helps['functionapp deployment config show'] = """ type: command short-summary: Get the details of a function app's deployment configuration. +long-summary: > + For Registry deployment storage, shows the image reference and authentication type, with the user-assigned + identity resource ID or the names of the app settings that hold Basic credentials. Credential values aren't + retrieved or shown. examples: - name: Get the details of a function app's deployment configuration. text: az functionapp deployment config show --name MyFunctionApp --resource-group MyResourceGroup @@ -796,7 +800,7 @@ - name: Create a flex consumption function app. See https://aka.ms/flex-http-concurrency for more information on default http concurrency values. text: > az functionapp create -g MyResourceGroup --name MyUniqueAppName -s MyStorageAccount --flexconsumption-location northeurope --runtime java --instance-memory 2048 - - name: Create a flex consumption function app that runs a container image from Azure Container Registry, pulled with the app's system-assigned identity. The service accepting the configuration doesn't prove that the image can be pulled. + - name: Create a flex consumption function app that runs a container image from Azure Container Registry, pulled with the app's system-assigned identity. The service accepting the configuration doesn't prove that the registry is reachable, that access is authorized, or that deployment succeeds. text: > az functionapp create -g MyResourceGroup --name MyUniqueAppName -s MyStorageAccount --flexconsumption-location northeurope --deployment-image myregistry.azurecr.io/myimage:v1 --deployment-image-auth-type SystemAssignedIdentity --assign-identity [system] --role AcrPull --scope /subscriptions//resourceGroups/MyResourceGroup/providers/Microsoft.ContainerRegistry/registries/myregistry """ 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 3ae23080854..06a054e8767 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -1729,6 +1729,15 @@ def _is_flex_registry_storage(deployment_storage): return (deployment_storage.get("type") or "").lower() == "registry" +def _get_raw_flex_functionapp(cli_ctx, resource_group_name, name): + # Registry apps are re-read with the API version that publishes the Registry contract; other apps are unchanged. + functionapp = get_raw_functionapp(cli_ctx, resource_group_name, name) + function_app_config = functionapp.get("properties", {}).get("functionAppConfig") or {} + if _is_flex_registry_storage((function_app_config.get("deployment") or {}).get("storage") or {}): + functionapp = get_raw_functionapp(cli_ctx, resource_group_name, name, api_version=FLEX_REGISTRY_API_VERSION) + return functionapp + + def _build_flex_registry_authentication(auth_type, identity=None, username_setting=None, password_setting=None, server_url=None): """Return the Registry authentication object for exactly one mode, or None when no auth argument is given.""" @@ -2884,7 +2893,7 @@ def list_function_app(cmd, resource_group_name=None): def show_functionapp(cmd, resource_group_name, name, slot=None): if is_flex_functionapp(cmd.cli_ctx, resource_group_name, name): - return get_raw_functionapp(cmd.cli_ctx, resource_group_name, name) + return _get_raw_flex_functionapp(cmd.cli_ctx, resource_group_name, name) app = _generic_site_operation(cmd.cli_ctx, resource_group_name, name, 'get', slot) if not app: raise ResourceNotFoundError("Unable to find resource'{}', in ResourceGroup '{}'.".format(name, @@ -4214,7 +4223,7 @@ def _get_linux_multicontainer_encoded_config_from_file(file_name): def get_deployment_configs(cmd, resource_group_name, name): - functionapp = get_raw_functionapp(cmd.cli_ctx, resource_group_name, name, api_version=FLEX_REGISTRY_API_VERSION) + functionapp = _get_raw_flex_functionapp(cmd.cli_ctx, resource_group_name, name) return functionapp.get("properties", {}).get("functionAppConfig", {}).get( "deployment", {}) 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 65f2e188ed8..cf272d04c7b 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 @@ -1767,7 +1767,6 @@ def test_functionapp_flex_deployment_config_by_user_identity_id(self, functionap self.assertTrue(deployment_config['storage']['authentication']['userAssignedIdentityResourceId'] == identity['id']) self.assertTrue(deployment_config['storage']['authentication']['storageAccountConnectionStringName'] is None) - @ResourceGroupPreparer(location=FLEX_ASP_LOCATION_FUNCTIONAPP) @StorageAccountPreparer() def test_functionapp_flex_registry_deployment(self, resource_group, storage_account): @@ -1789,9 +1788,14 @@ def test_functionapp_flex_registry_deployment(self, resource_group, storage_acco JMESPathCheck(storage + '.value', image), JMESPathCheck(storage + '.authentication.type', 'UserAssignedIdentity'), JMESPathCheck(storage + '.authentication.userAssignedIdentityResourceId', identity['id'])]) + self.cmd('functionapp show -g {} -n {}'.format(resource_group, uai_app), checks=[ + JMESPathCheck('properties.functionAppConfig.runtime', None), + JMESPathCheck(storage + '.value', image), + JMESPathCheck(storage + '.authentication.userAssignedIdentityResourceId', identity['id'])]) self.cmd('functionapp deployment config show -g {} -n {}'.format(resource_group, uai_app), checks=[ JMESPathCheck('storage.type', 'Registry'), - JMESPathCheck('storage.value', image)]) + JMESPathCheck('storage.value', image), + JMESPathCheck('storage.authentication.userAssignedIdentityResourceId', identity['id'])]) self.cmd('functionapp deployment config set -g {} -n {} --deployment-image-auth-type Basic ' '--deployment-image-username-setting REGISTRY_USERNAME --deployment-image-password-setting REGISTRY_PASSWORD ' '--deployment-image-server-url https://mcr.microsoft.com'.format(resource_group, uai_app), checks=[ @@ -1801,6 +1805,13 @@ def test_functionapp_flex_registry_deployment(self, resource_group, storage_acco JMESPathCheck('storage.authentication.passwordSettingName', 'REGISTRY_PASSWORD'), JMESPathCheck('storage.authentication.serverUrl', 'https://mcr.microsoft.com'), JMESPathCheck('storage.authentication.userAssignedIdentityResourceId', None)]) + # The service rejects a blank image; the accepted configuration must be unchanged. + self.cmd("functionapp deployment config set -g {} -n {} --deployment-image ' '".format(resource_group, uai_app), + expect_failure=True) + self.cmd('functionapp deployment config show -g {} -n {}'.format(resource_group, uai_app), checks=[ + JMESPathCheck('storage.value', image), + JMESPathCheck('storage.authentication.type', 'Basic'), + JMESPathCheck('storage.authentication.passwordSettingName', 'REGISTRY_PASSWORD')]) basic_app = self.create_random_name('functionapp', 40) self.cmd('functionapp create -g {} -n {} -f {} -s {} --deployment-image {} --deployment-image-auth-type Basic ' @@ -1811,6 +1822,12 @@ def test_functionapp_flex_registry_deployment(self, resource_group, storage_acco JMESPathCheck(storage + '.authentication.type', 'Basic'), JMESPathCheck(storage + '.authentication.passwordSettingName', 'REGISTRY_PASSWORD'), JMESPathCheck(storage + '.authentication.serverUrl', 'https://mcr.microsoft.com')]) + tag_digest_image = image + '@sha256:' + 'b' * 64 + self.cmd('functionapp deployment config set -g {} -n {} --deployment-image {}' + .format(resource_group, basic_app, tag_digest_image)) + self.cmd('functionapp deployment config show -g {} -n {}'.format(resource_group, basic_app), checks=[ + JMESPathCheck('storage.value', tag_digest_image), + JMESPathCheck('storage.authentication.usernameSettingName', 'REGISTRY_USERNAME')]) blob_app = self.create_random_name('functionapp', 40) self.cmd('functionapp create -g {} -n {} -f {} -s {} --runtime python --runtime-version 3.11' 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 52dd16b8a16..deda8916661 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 @@ -31,6 +31,7 @@ revert_flex_migration, create_functionapp, get_deployment_configs, + show_functionapp, update_deployment_configs) from azure.cli.core.profiles import ResourceType from azure.cli.core.azclierror import (AzureInternalError, UnclassifiedUserFault, HTTPError) @@ -1463,6 +1464,8 @@ def _flex_blob_site(): 'tags': {'team': 'functions'}, 'properties': { 'sku': 'FlexConsumption', + 'serverFarmId': '/subscriptions/{}/resourceGroups/rg/providers/Microsoft.Web/serverfarms/plan'.format( + _REGISTRY_SUBSCRIPTION), 'unknownSiteProperty': {'keep': True}, 'functionAppConfig': { 'deployment': { @@ -1520,7 +1523,8 @@ def _with_registry_storage(site, storage): class _FakeArmSite: """Stands in for ARM behind requests.Session.send, so the real send_raw_request builds, sends and logs requests. - Stores one site, echoes accepted PUT bodies, and serves a sentinel secret from the app settings endpoint. + Stores one site, echoes accepted PUT bodies, serves a sentinel secret from the app settings endpoint, and leaves + the Registry authentication fields out of GET responses for API versions older than 2025-05-01. """ def __init__(self, site, put_error=None): @@ -1535,6 +1539,12 @@ def send(self, _session, request, **_kwargs): status, payload = 200, self.site if '/config/appsettings/list' in request.url: payload = {'properties': {'REGISTRY_PASSWORD': _REGISTRY_SECRET}} + elif request.method == 'GET' and 'api-version=2025-05-01' not in request.url: + # Only 2025-05-01 publishes the Registry authentication fields, so older versions may omit them. + payload = json.loads(json.dumps(self.site)) + authentication = payload['properties']['functionAppConfig']['deployment']['storage']['authentication'] + for field in ('usernameSettingName', 'passwordSettingName', 'serverUrl'): + authentication.pop(field, None) elif request.method == 'PUT': if self.put_error: status, payload = 400, self.put_error @@ -1567,7 +1577,8 @@ def _fake_arm(site, put_error=None): class TestFlexRegistryDeploymentConfigMocked(unittest.TestCase): - """`functionapp deployment config set/show` with Registry storage, asserted on the HTTP requests sent to ARM.""" + """`functionapp deployment config set/show` and `functionapp show` with Registry storage, asserted on the HTTP + requests sent to ARM.""" def setUp(self): self.cmd = _get_test_cmd() @@ -1599,11 +1610,33 @@ def test_set_switches_blob_app_to_registry_with_exact_payload(self): self.assertEqual(arm.calls(), [('GET', _REGISTRY_SITE, '2025-05-01'), ('PUT', _REGISTRY_SITE, '2025-05-01'), + ('GET', _REGISTRY_SITE, '2023-12-01'), ('GET', _REGISTRY_SITE, '2025-05-01')]) self.assertEqual(arm.requests[1][2], _with_registry_storage(_flex_blob_site(), storage)) self.assertEqual(result, {'storage': storage}) self.assertEqual(shown, {'storage': storage}) + def test_show_reads_registry_apps_with_the_registry_api_version_and_blob_apps_unchanged(self): + registry_deployment = _flex_registry_site()['properties']['functionAppConfig']['deployment'] + blob_deployment = _flex_blob_site()['properties']['functionAppConfig']['deployment'] + cases = [ + ('functionapp show, Registry', show_functionapp, _flex_registry_site, + ['2023-12-01', '2023-12-01', '2025-05-01'], _flex_registry_site()), + ('functionapp show, blob', show_functionapp, _flex_blob_site, + ['2023-12-01', '2023-12-01'], _flex_blob_site()), + ('deployment config show, Registry', get_deployment_configs, _flex_registry_site, + ['2023-12-01', '2025-05-01'], registry_deployment), + ('deployment config show, blob', get_deployment_configs, _flex_blob_site, + ['2023-12-01'], blob_deployment), + ] + for case, show, site, api_versions, expected in cases: + with self.subTest(case): + with _fake_arm(site()) as arm: + result = show(self.cmd, 'rg', 'app') + + self.assertEqual(arm.calls(), [('GET', _REGISTRY_SITE, version) for version in api_versions]) + self.assertEqual(result, expected) + def test_set_on_registry_app_keeps_the_unspecified_image_or_authentication(self): cases = [ ('image only', {'deployment_image': 'myacr.azurecr.io/app:v2'}, @@ -1654,31 +1687,36 @@ def test_set_rejects_invalid_registry_arguments_without_writing(self): update_deployment_configs(self.cmd, 'rg', 'app', **args) self.assertNotIn('PUT', [method for method, _, _ in arm.requests]) - def test_set_surfaces_field_scoped_service_rejection_after_a_single_write(self): + def test_set_surfaces_service_rejection_and_show_confirms_the_previous_config(self): # The CLI doesn't parse image references; the service rejects invalid ones with a field-scoped error. field_error = {'Code': 'BadRequest', 'Message': 'The parameter Site.FunctionAppConfig.Deployment.Storage.Value ' 'has an invalid value.'} - with _fake_arm(_flex_blob_site(), put_error=field_error) as arm, self.assertRaises(HTTPError) as error: - update_deployment_configs(self.cmd, 'rg', 'app', deployment_image='myacr.azurecr.io/App:Latest!', - deployment_image_auth_type='Anonymous') + with _fake_arm(_flex_registry_site(), put_error=field_error) as arm: + with self.assertRaises(HTTPError) as error: + update_deployment_configs(self.cmd, 'rg', 'app', deployment_image='myacr.azurecr.io/App:Latest!') + shown = get_deployment_configs(self.cmd, 'rg', 'app') self.assertIn('Site.FunctionAppConfig.Deployment.Storage.Value', str(error.exception)) - self.assertEqual([method for method, _, _ in arm.requests], ['GET', 'PUT']) + self.assertEqual([method for method, _, _ in arm.requests], ['GET', 'PUT', 'GET', 'GET']) self.assertEqual(arm.requests[1][2]['properties']['functionAppConfig']['deployment']['storage']['value'], 'myacr.azurecr.io/App:Latest!') + self.assertEqual(shown, _flex_registry_site()['properties']['functionAppConfig']['deployment']) - def test_set_debug_log_shows_setting_names_but_never_registry_secrets(self): - with _fake_arm(_flex_blob_site()) as arm, \ + def test_set_and_show_log_setting_names_but_never_registry_secrets(self): + with _fake_arm(_flex_blob_site()), \ self.assertLogs('cli.azure.cli.core.util', level='DEBUG') as logs: - result = update_deployment_configs( - self.cmd, 'rg', 'app', deployment_image='myacr.azurecr.io/app:v1', deployment_image_auth_type='Basic', - deployment_image_username_setting='REGISTRY_USERNAME', - deployment_image_password_setting='REGISTRY_PASSWORD') - - debug_log = '\n'.join(logs.output) + outputs = [ + update_deployment_configs( + self.cmd, 'rg', 'app', deployment_image='myacr.azurecr.io/app:v1', + deployment_image_auth_type='Basic', deployment_image_username_setting='REGISTRY_USERNAME', + deployment_image_password_setting='REGISTRY_PASSWORD'), + get_deployment_configs(self.cmd, 'rg', 'app'), + show_functionapp(self.cmd, 'rg', 'app')] + + debug_log, output = '\n'.join(logs.output), json.dumps(outputs) self.assertIn('"passwordSettingName": "REGISTRY_PASSWORD"', debug_log) - self.assertNotIn(_REGISTRY_SECRET, debug_log + json.dumps(result)) - self.assertEqual([method for method, _, _ in arm.requests], ['GET', 'PUT']) + self.assertEqual(output.count('"passwordSettingName": "REGISTRY_PASSWORD"'), 3) + self.assertNotIn(_REGISTRY_SECRET, debug_log + output) class TestFlexRegistryCreateMocked(unittest.TestCase): @@ -1724,6 +1762,9 @@ def test_create_sends_registry_config_without_runtime_and_with_default_scale(sel 'authentication': {'type': 'SystemAssignedIdentity'}}}, 'scaleAndConcurrency': {'maximumInstanceCount': 100, 'instanceMemoryMB': 2048, 'alwaysReady': []} }) + # None of the legacy Linux-container markers: container kind, linuxFxVersion or DOCKER_* app settings. + self.assertEqual(site['kind'], 'functionapp,linux') + self.assertNotIn('linuxFxVersion', site['properties']['siteConfig']) self.assertEqual([setting['name'] for setting in site['properties']['siteConfig']['appSettings']], ['AzureWebJobsStorage']) self.runtime_helper.assert_not_called() From 19377d9b450c8d4b25a14c1a726e98596a673c16 Mon Sep 17 00:00:00 2001 From: PragatiKushwaha Date: Tue, 29 Sep 2026 16:48:57 +0530 Subject: [PATCH 3/5] [App Service] Preserve Flex Registry configuration in all Flex config updates Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../cli/command_modules/appservice/_help.py | 3 + .../cli/command_modules/appservice/custom.py | 45 ++++++++----- .../test_functionapp_commands_thru_mock.py | 67 ++++++++++++++++++- 3 files changed, 98 insertions(+), 17 deletions(-) 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 63571d3638a..095bc143d02 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -644,6 +644,9 @@ helps['functionapp runtime config set'] = """ type: command short-summary: Update an existing function app's runtime configuration. +long-summary: > + Registry deployment storage has no runtime. Use `az functionapp deployment config set` to update its container + image instead. This command updates the runtime for Flex apps using blob container deployment storage. examples: - name: Set the function app's runtime version. text: az functionapp runtime config set --name MyFunctionApp --resource-group MyResourceGroup --runtime-version 3.11 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 06a054e8767..32648aac00b 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -4355,14 +4355,10 @@ def _update_flex_registry_deployment_config(cmd, resource_group_name, name, imag storage["type"] = "Registry" if image is not None: storage["value"] = image - if authentication is None: - # Keep the current mode; GET returns the fields of other modes as null. - authentication = {key: value for key, value in storage["authentication"].items() if value is not None} - storage["authentication"] = authentication - function_app_config.pop("runtime", None) - - result = update_flex_functionapp(cmd, resource_group_name, name, functionapp, - api_version=FLEX_REGISTRY_API_VERSION) + if authentication is not None: + storage["authentication"] = authentication + + result = _update_flex_functionapp_config(cmd, resource_group_name, name, functionapp) return result.get("properties", {}).get("functionAppConfig", {}).get("deployment", {}) @@ -4578,8 +4574,21 @@ def update_flex_functionapp(cmd, resource_group_name, name, functionapp, api_ver return response.json() +def _update_flex_functionapp_config(cmd, resource_group_name, name, functionapp): + function_app_config = functionapp["properties"]["functionAppConfig"] + storage = function_app_config["deployment"]["storage"] + if _is_flex_registry_storage(storage): + # GET may include null fields from other auth modes and a null runtime; Registry PUT must omit them. + function_app_config.pop("runtime", None) + storage["authentication"] = {key: value for key, value in storage["authentication"].items() + if value is not None} + return update_flex_functionapp(cmd, resource_group_name, name, functionapp, + api_version=FLEX_REGISTRY_API_VERSION) + return update_flex_functionapp(cmd, resource_group_name, name, functionapp) + + def delete_always_ready_settings(cmd, resource_group_name, name, setting_names): - functionapp = get_raw_functionapp(cmd.cli_ctx, resource_group_name, name) + functionapp = _get_raw_flex_functionapp(cmd.cli_ctx, resource_group_name, name) always_ready_config = functionapp["properties"]["functionAppConfig"]["scaleAndConcurrency"].get("alwaysReady", []) @@ -4587,7 +4596,7 @@ def delete_always_ready_settings(cmd, resource_group_name, name, setting_names): functionapp["properties"]["functionAppConfig"]["scaleAndConcurrency"]["alwaysReady"] = updated_always_ready_config - result = update_flex_functionapp(cmd, resource_group_name, name, functionapp) + result = _update_flex_functionapp_config(cmd, resource_group_name, name, functionapp) return result.get("properties", {}).get("functionAppConfig", {}).get( "scaleAndConcurrency", {}) @@ -4602,6 +4611,10 @@ def get_runtime_config(cmd, resource_group_name, name): def update_runtime_config(cmd, resource_group_name, name, runtime_version): functionapp = get_raw_functionapp(cmd.cli_ctx, resource_group_name, name) + storage = functionapp["properties"]["functionAppConfig"]["deployment"]["storage"] + if _is_flex_registry_storage(storage): + raise ValidationError('Registry deployment storage has no runtime. Use functionapp deployment config set ' + 'to update the container image.') runtime_info = _get_functionapp_runtime_info(cmd, resource_group_name, name, None, True) runtime = runtime_info['app_runtime'] @@ -4621,7 +4634,7 @@ def update_runtime_config(cmd, resource_group_name, name, runtime_version): def update_always_ready_settings(cmd, resource_group_name, name, settings): - functionapp = get_raw_functionapp(cmd.cli_ctx, resource_group_name, name) + functionapp = _get_raw_flex_functionapp(cmd.cli_ctx, resource_group_name, name) if functionapp["properties"]["functionAppConfig"]["scaleAndConcurrency"].get("alwaysReady") is None: functionapp["properties"]["functionAppConfig"]["scaleAndConcurrency"]["alwaysReady"] = [] @@ -4645,7 +4658,7 @@ def update_always_ready_settings(cmd, resource_group_name, name, settings): functionapp["properties"]["functionAppConfig"]["scaleAndConcurrency"]["alwaysReady"] = updated_always_ready_config - result = update_flex_functionapp(cmd, resource_group_name, name, functionapp) + result = _update_flex_functionapp_config(cmd, resource_group_name, name, functionapp) return result.get("properties", {}).get("functionAppConfig", {}).get( "scaleAndConcurrency", {}) @@ -4664,7 +4677,7 @@ def update_scale_config(cmd, resource_group_name, name, maximum_instance_count=N raise RequiredArgumentMissingError("usage error: --trigger-type must be used with parameter " "--trigger-settings.") - functionapp = get_raw_functionapp(cmd.cli_ctx, resource_group_name, name) + functionapp = _get_raw_flex_functionapp(cmd.cli_ctx, resource_group_name, name) scale_config = functionapp["properties"]["functionAppConfig"]["scaleAndConcurrency"] @@ -4685,7 +4698,7 @@ def update_scale_config(cmd, resource_group_name, name, maximum_instance_count=N functionapp["properties"]["functionAppConfig"]["scaleAndConcurrency"] = scale_config - result = update_flex_functionapp(cmd, resource_group_name, name, functionapp) + result = _update_flex_functionapp_config(cmd, resource_group_name, name, functionapp) return result.get("properties", {}).get("functionAppConfig", {}).get( "scaleAndConcurrency", {}) @@ -4715,7 +4728,7 @@ def set_update_strategy_config(cmd, resource_group_name, name, strategy_type): f"Allowed values are: {', '.join(UPDATE_STRATEGY_TYPES)}." ) - functionapp = get_raw_functionapp(cmd.cli_ctx, resource_group_name, name) + functionapp = _get_raw_flex_functionapp(cmd.cli_ctx, resource_group_name, name) # Initialize siteUpdateStrategy if it doesn't exist if "siteUpdateStrategy" not in functionapp["properties"]["functionAppConfig"]: @@ -4723,7 +4736,7 @@ def set_update_strategy_config(cmd, resource_group_name, name, strategy_type): functionapp["properties"]["functionAppConfig"]["siteUpdateStrategy"]["type"] = matched_type - result = update_flex_functionapp(cmd, resource_group_name, name, functionapp) + result = _update_flex_functionapp_config(cmd, resource_group_name, name, functionapp) return result.get("properties", {}).get("functionAppConfig", {}).get( "siteUpdateStrategy", {}) 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 deda8916661..e6e2249cd71 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 @@ -30,9 +30,14 @@ _prepare_flex_deployment_storage_identity, revert_flex_migration, create_functionapp, + delete_always_ready_settings, get_deployment_configs, + set_update_strategy_config, show_functionapp, - update_deployment_configs) + update_always_ready_settings, + update_deployment_configs, + update_runtime_config, + update_scale_config) from azure.cli.core.profiles import ResourceType from azure.cli.core.azclierror import (AzureInternalError, UnclassifiedUserFault, HTTPError) from azure.cli.core.azclierror import (ResourceNotFoundError, MutuallyExclusiveArgumentError, @@ -1718,6 +1723,66 @@ def test_set_and_show_log_setting_names_but_never_registry_secrets(self): self.assertEqual(output.count('"passwordSettingName": "REGISTRY_PASSWORD"'), 3) self.assertNotIn(_REGISTRY_SECRET, debug_log + output) + def test_other_flex_config_writes_preserve_basic_registry_storage(self): + cases = [ + ('scale', update_scale_config, {'maximum_instance_count': 50}, + 'scaleAndConcurrency', 'maximumInstanceCount', 50), + ('always-ready set', update_always_ready_settings, {'settings': ['http=2']}, + 'scaleAndConcurrency', 'alwaysReady', [{'name': 'http', 'instanceCount': 2}]), + ('always-ready delete', delete_always_ready_settings, {'setting_names': ['http']}, + 'scaleAndConcurrency', 'alwaysReady', []), + ('update strategy', set_update_strategy_config, {'strategy_type': 'Recreate'}, + 'siteUpdateStrategy', 'type', 'Recreate'), + ] + authentication = {'type': 'Basic', 'usernameSettingName': 'REGISTRY_USERNAME', + 'passwordSettingName': 'REGISTRY_PASSWORD', 'serverUrl': 'https://myacr.azurecr.io'} + for case, update, args, section, field, expected in cases: + with self.subTest(case): + site = _flex_registry_site() + site['properties']['functionAppConfig']['deployment']['storage']['authentication'].update( + authentication, userAssignedIdentityResourceId=None) + with _fake_arm(site) as arm: + update(self.cmd, 'rg', 'app', **args) + shown = get_deployment_configs(self.cmd, 'rg', 'app') + + self.assertEqual(arm.calls(), [('GET', _REGISTRY_SITE, '2023-12-01'), + ('GET', _REGISTRY_SITE, '2025-05-01'), + ('PUT', _REGISTRY_SITE, '2025-05-01'), + ('GET', _REGISTRY_SITE, '2023-12-01'), + ('GET', _REGISTRY_SITE, '2025-05-01')]) + sent = arm.requests[2][2]['properties']['functionAppConfig'] + self.assertEqual(sent['deployment']['storage']['authentication'], authentication) + self.assertEqual(shown['storage']['authentication'], authentication) + self.assertEqual(shown['storage']['value'], 'myacr.azurecr.io/app:v1') + self.assertNotIn('runtime', sent) + self.assertEqual(sent[section][field], expected) + self.assertEqual(sent['unknownFunctionAppConfigProperty'], {'keep': True}) + + def test_other_flex_config_writes_keep_blob_requests_unchanged(self): + cases = [ + ('scale', update_scale_config, {'maximum_instance_count': 50}), + ('always-ready set', update_always_ready_settings, {'settings': ['http=2']}), + ('always-ready delete', delete_always_ready_settings, {'setting_names': ['http']}), + ('update strategy', set_update_strategy_config, {'strategy_type': 'Recreate'}), + ] + for case, update, args in cases: + with self.subTest(case): + site = _flex_blob_site() + with _fake_arm(site) as arm: + update(self.cmd, 'rg', 'app', **args) + + self.assertEqual(arm.calls(), [('GET', _REGISTRY_SITE, '2023-12-01'), + ('PUT', _REGISTRY_SITE, '2023-12-01')]) + sent = arm.requests[1][2]['properties']['functionAppConfig'] + self.assertEqual(sent['deployment'], site['properties']['functionAppConfig']['deployment']) + self.assertEqual(sent['runtime'], {'name': 'python', 'version': '3.11'}) + + def test_runtime_set_rejects_registry_storage_without_writing(self): + with _fake_arm(_flex_registry_site()) as arm, self.assertRaisesRegex(ValidationError, 'Registry.*runtime'): + update_runtime_config(self.cmd, 'rg', 'app', runtime_version='3.12') + + self.assertEqual(arm.calls(), [('GET', _REGISTRY_SITE, '2023-12-01')]) + class TestFlexRegistryCreateMocked(unittest.TestCase): """`functionapp create --deployment-image` builds a Registry functionAppConfig without a runtime.""" From 5c8dc268d1bfd0bead00a8b54dfb98fbe8d4bcd0 Mon Sep 17 00:00:00 2001 From: PragatiKushwaha Date: Wed, 30 Sep 2026 15:41:03 +0530 Subject: [PATCH 4/5] [App Service] Preserve Flex Registry Basic authentication during identity changes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../command_modules/appservice/_constants.py | 4 +- .../cli/command_modules/appservice/_help.py | 4 +- .../cli/command_modules/appservice/custom.py | 49 +++++++++++---- .../test_functionapp_commands_thru_mock.py | 60 ++++++++++++++++++- 4 files changed, 101 insertions(+), 16 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_constants.py b/src/azure-cli/azure/cli/command_modules/appservice/_constants.py index 497a8756350..d59dd637179 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_constants.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_constants.py @@ -151,8 +151,8 @@ def __init__(self): FLEX_REGISTRY_API_VERSION = '2025-05-01' -# Registry apps have no runtime stack to supply scale defaults, so use the Flex Consumption stack defaults. -FLEX_DEFAULT_MAXIMUM_INSTANCE_COUNT = 100 +# Registry apps have no runtime stack to supply scale defaults. +FLEX_DEFAULT_MAXIMUM_INSTANCE_COUNT = 1000 FLEX_DEFAULT_INSTANCE_MEMORY_MB = 2048 UPDATE_STRATEGY_TYPES = ['Recreate', 'RollingUpdate'] 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 095bc143d02..25430ac08fb 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -614,9 +614,9 @@ - name: Pull a container image by digest with the app's system-assigned identity. text: az functionapp deployment config set --name MyFunctionApp --resource-group MyResourceGroup --deployment-image myregistry.azurecr.io/myimage@sha256: --deployment-image-auth-type SystemAssignedIdentity - name: Pull the container image with a user-assigned identity. - text: az functionapp deployment config set --name MyFunctionApp --resource-group MyResourceGroup --deployment-image-auth-type UserAssignedIdentity --deployment-image-identity /subscriptions//resourceGroups/MyResourceGroup/providers/Microsoft.ManagedIdentity/userAssignedIdentities/MyIdentity + text: az functionapp deployment config set --name MyFunctionApp --resource-group MyResourceGroup --deployment-image myregistry.azurecr.io/myimage:v1 --deployment-image-auth-type UserAssignedIdentity --deployment-image-identity /subscriptions//resourceGroups/MyResourceGroup/providers/Microsoft.ManagedIdentity/userAssignedIdentities/MyIdentity - name: Pull the container image with a username and password stored in app settings (Basic authentication). - text: az functionapp deployment config set --name MyFunctionApp --resource-group MyResourceGroup --deployment-image-auth-type Basic --deployment-image-username-setting REGISTRY_USERNAME --deployment-image-password-setting REGISTRY_PASSWORD --deployment-image-server-url https://myregistry.azurecr.io + text: az functionapp deployment config set --name MyFunctionApp --resource-group MyResourceGroup --deployment-image myregistry.azurecr.io/myimage:v1 --deployment-image-auth-type Basic --deployment-image-username-setting REGISTRY_USERNAME --deployment-image-password-setting REGISTRY_PASSWORD --deployment-image-server-url https://myregistry.azurecr.io """ helps['functionapp deployment config show'] = """ 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 32648aac00b..6ff3b0ab910 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -1729,6 +1729,13 @@ def _is_flex_registry_storage(deployment_storage): return (deployment_storage.get("type") or "").lower() == "registry" +def _flex_registry_config_from_site(site): + config = getattr(getattr(site, 'properties', None), 'function_app_config', None) + if config and _is_flex_registry_storage((config.get('deployment') or {}).get('storage') or {}): + return config + return None + + def _get_raw_flex_functionapp(cli_ctx, resource_group_name, name): # Registry apps are re-read with the API version that publishes the Registry contract; other apps are unchanged. functionapp = get_raw_functionapp(cli_ctx, resource_group_name, name) @@ -3816,6 +3823,24 @@ def _convert_webapp_to_docker(cmd, name, resource_group, slot, yes=False): logger.warning("Webapp '%s' converted to classic custom container (docker) mode.", name) +def _get_site_for_identity_update(cli_ctx, resource_group_name, name, slot): + webapp = _generic_site_operation(cli_ctx, resource_group_name, name, 'get', slot) + if _flex_registry_config_from_site(webapp): + return _generic_site_operation(cli_ctx, resource_group_name, name, 'get', slot, + api_version=FLEX_REGISTRY_API_VERSION) + return webapp + + +def _persist_identity_update(cmd, resource_group_name, name, slot, webapp): + registry_config = _flex_registry_config_from_site(webapp) + if registry_config: + _prepare_flex_registry_config_for_update(registry_config) + poller = _generic_site_operation(cmd.cli_ctx, resource_group_name, name, 'begin_create_or_update', + extra_parameter=webapp, slot=slot, + api_version=FLEX_REGISTRY_API_VERSION if registry_config else None) + return LongRunningOperation(cmd.cli_ctx)(poller) + + def assign_identity(cmd, resource_group_name, name, assign_identities=None, role='Contributor', slot=None, scope=None): ManagedServiceIdentity, ResourceIdentityType = cmd.get_models('ManagedServiceIdentity', 'ManagedServiceIdentityType') @@ -3823,7 +3848,7 @@ def assign_identity(cmd, resource_group_name, name, assign_identities=None, role _, _, external_identities, enable_local_identity = _build_identities_info(assign_identities) def getter(): - return _generic_site_operation(cmd.cli_ctx, resource_group_name, name, 'get', slot) + return _get_site_for_identity_update(cmd.cli_ctx, resource_group_name, name, slot) def setter(webapp): if webapp.identity and webapp.identity.type == ResourceIdentityType.system_assigned_user_assigned: @@ -3849,9 +3874,7 @@ def setter(webapp): for identity in external_identities: webapp.identity.user_assigned_identities[identity] = UserAssignedIdentitiesValue() - poller = _generic_site_operation(cmd.cli_ctx, resource_group_name, name, 'begin_create_or_update', - extra_parameter=webapp, slot=slot) - return LongRunningOperation(cmd.cli_ctx)(poller) + return _persist_identity_update(cmd, resource_group_name, name, slot, webapp) from azure.cli.core.commands.arm import assign_identity as _assign_identity webapp = _assign_identity(cmd.cli_ctx, getter, setter, identity_role=role, identity_scope=scope) @@ -3871,7 +3894,7 @@ def remove_identity(cmd, resource_group_name, name, remove_identities=None, slot _, _, external_identities, remove_local_identity = _build_identities_info(remove_identities) def getter(): - return _generic_site_operation(cmd.cli_ctx, resource_group_name, name, 'get', slot) + return _get_site_for_identity_update(cmd.cli_ctx, resource_group_name, name, slot) def setter(webapp): if webapp.identity is None: @@ -3905,8 +3928,7 @@ def setter(webapp): for identity in list(existing_identities): webapp.identity.user_assigned_identities[identity] = UserAssignedIdentitiesValue() - poller = _generic_site_operation(cmd.cli_ctx, resource_group_name, name, 'begin_create_or_update', slot, webapp) - return LongRunningOperation(cmd.cli_ctx)(poller) + return _persist_identity_update(cmd, resource_group_name, name, slot, webapp) from azure.cli.core.commands.arm import assign_identity as _assign_identity webapp = _assign_identity(cmd.cli_ctx, getter, setter) @@ -4574,14 +4596,19 @@ def update_flex_functionapp(cmd, resource_group_name, name, functionapp, api_ver return response.json() +def _prepare_flex_registry_config_for_update(function_app_config): + # GET may include null fields from other auth modes and a null runtime; Registry PUT must omit them. + function_app_config.pop("runtime", None) + storage = function_app_config["deployment"]["storage"] + storage["authentication"] = {key: value for key, value in storage["authentication"].items() + if value is not None} + + def _update_flex_functionapp_config(cmd, resource_group_name, name, functionapp): function_app_config = functionapp["properties"]["functionAppConfig"] storage = function_app_config["deployment"]["storage"] if _is_flex_registry_storage(storage): - # GET may include null fields from other auth modes and a null runtime; Registry PUT must omit them. - function_app_config.pop("runtime", None) - storage["authentication"] = {key: value for key, value in storage["authentication"].items() - if value is not None} + _prepare_flex_registry_config_for_update(function_app_config) return update_flex_functionapp(cmd, resource_group_name, name, functionapp, api_version=FLEX_REGISTRY_API_VERSION) return update_flex_functionapp(cmd, resource_group_name, name, functionapp) 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 e6e2249cd71..4a69430ea32 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 @@ -29,9 +29,11 @@ _prepare_flex_deployment_storage, _prepare_flex_deployment_storage_identity, revert_flex_migration, + assign_identity, create_functionapp, delete_always_ready_settings, get_deployment_configs, + remove_identity, set_update_strategy_config, show_functionapp, update_always_ready_settings, @@ -1560,6 +1562,8 @@ def send(self, _session, request, **_kwargs): response.reason = 'OK' if status == 200 else 'Bad Request' response.headers['Content-Type'] = 'application/json' response._content = json.dumps(payload).encode() # pylint: disable=protected-access + response.raw = mock.Mock() + response.raw.stream.return_value = iter([response._content]) # pylint: disable=protected-access response.url = request.url return response @@ -1784,6 +1788,60 @@ def test_runtime_set_rejects_registry_storage_without_writing(self): self.assertEqual(arm.calls(), [('GET', _REGISTRY_SITE, '2023-12-01')]) +class TestFlexRegistryIdentityMocked(unittest.TestCase): + def test_identity_changes_preserve_registry_authentication_and_blob_requests(self): + from azure.core.credentials import AccessToken + + credential = mock.Mock() + credential.get_token.return_value = AccessToken('token', 2147483647) + + def client_factory(_cli_ctx, api_version=None): + return WebSiteManagementClient(credential, _REGISTRY_SUBSCRIPTION, + api_version=api_version or '2023-12-01') + + actions = [ + ('assign', assign_identity, None, 'SystemAssigned'), + ('remove', remove_identity, {'type': 'SystemAssigned'}, 'None'), + ] + storage_cases = [ + ('Registry Basic', _flex_registry_site, ['2023-12-01', '2025-05-01', '2025-05-01']), + ('Blob', _flex_blob_site, ['2023-12-01', '2023-12-01']), + ] + for action, update, starting_identity, expected_identity_type in actions: + for storage_type, make_site, versions in storage_cases: + with self.subTest(action=action, storage=storage_type): + site = make_site() + if starting_identity: + site['identity'] = starting_identity + if storage_type == 'Registry Basic': + site['properties']['functionAppConfig']['deployment']['storage']['authentication'].update( + type='Basic', userAssignedIdentityResourceId=None, + usernameSettingName='REGISTRY_USERNAME', passwordSettingName='REGISTRY_PASSWORD') + cmd = _get_test_cmd() + with _fake_arm(site) as arm, \ + mock.patch('azure.cli.command_modules.appservice._appservice_utils.web_client_factory', + side_effect=client_factory), \ + mock.patch('azure.cli.command_modules.appservice.custom.LongRunningOperation', + side_effect=lambda _ctx: lambda poller: poller.result()): + identity = update(cmd, 'rg', 'app', ['[system]']) + + self.assertEqual(arm.calls(), [(method, _REGISTRY_SITE, version) for method, version in + zip(['GET'] * (len(versions) - 1) + ['PUT'], versions)]) + sent = arm.requests[-1][2] + self.assertEqual(sent['identity']['type'], expected_identity_type) + self.assertIsNotNone(identity) + config = sent['properties']['functionAppConfig'] + if storage_type == 'Registry Basic': + self.assertEqual(config['deployment']['storage']['authentication'], { + 'type': 'Basic', 'usernameSettingName': 'REGISTRY_USERNAME', + 'passwordSettingName': 'REGISTRY_PASSWORD'}) + self.assertNotIn('runtime', config) + self.assertEqual(config['unknownFunctionAppConfigProperty'], {'keep': True}) + else: + self.assertEqual(config['deployment'], site['properties']['functionAppConfig']['deployment']) + self.assertEqual(config['runtime'], {'name': 'python', 'version': '3.11'}) + + class TestFlexRegistryCreateMocked(unittest.TestCase): """`functionapp create --deployment-image` builds a Registry functionAppConfig without a runtime.""" @@ -1825,7 +1883,7 @@ def test_create_sends_registry_config_without_runtime_and_with_default_scale(sel self.assertEqual(site['properties']['functionAppConfig'], { 'deployment': {'storage': {'type': 'Registry', 'value': image, 'authentication': {'type': 'SystemAssignedIdentity'}}}, - 'scaleAndConcurrency': {'maximumInstanceCount': 100, 'instanceMemoryMB': 2048, 'alwaysReady': []} + 'scaleAndConcurrency': {'maximumInstanceCount': 1000, 'instanceMemoryMB': 2048, 'alwaysReady': []} }) # None of the legacy Linux-container markers: container kind, linuxFxVersion or DOCKER_* app settings. self.assertEqual(site['kind'], 'functionapp,linux') From 65a24fdaaed1fefe730d0d34c83ea5cb5a108c19 Mon Sep 17 00:00:00 2001 From: PragatiKushwaha Date: Wed, 30 Sep 2026 16:04:39 +0530 Subject: [PATCH 5/5] [App Service] Remove Flex Registry server URL CLI option Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../cli/command_modules/appservice/_help.py | 2 +- .../cli/command_modules/appservice/_params.py | 2 -- .../cli/command_modules/appservice/custom.py | 22 +++++++------------ .../tests/latest/test_functionapp_commands.py | 11 ++++------ .../test_functionapp_commands_thru_mock.py | 22 +++++++++---------- 5 files changed, 23 insertions(+), 36 deletions(-) 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 25430ac08fb..97a8c1603e2 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -616,7 +616,7 @@ - name: Pull the container image with a user-assigned identity. text: az functionapp deployment config set --name MyFunctionApp --resource-group MyResourceGroup --deployment-image myregistry.azurecr.io/myimage:v1 --deployment-image-auth-type UserAssignedIdentity --deployment-image-identity /subscriptions//resourceGroups/MyResourceGroup/providers/Microsoft.ManagedIdentity/userAssignedIdentities/MyIdentity - name: Pull the container image with a username and password stored in app settings (Basic authentication). - text: az functionapp deployment config set --name MyFunctionApp --resource-group MyResourceGroup --deployment-image myregistry.azurecr.io/myimage:v1 --deployment-image-auth-type Basic --deployment-image-username-setting REGISTRY_USERNAME --deployment-image-password-setting REGISTRY_PASSWORD --deployment-image-server-url https://myregistry.azurecr.io + text: az functionapp deployment config set --name MyFunctionApp --resource-group MyResourceGroup --deployment-image myregistry.azurecr.io/myimage:v1 --deployment-image-auth-type Basic --deployment-image-username-setting REGISTRY_USERNAME --deployment-image-password-setting REGISTRY_PASSWORD """ helps['functionapp deployment config show'] = """ 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 739fe28cbdd..4422a19bb0b 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -1245,8 +1245,6 @@ def load_arguments(self, _): help="Name of the app setting that stores the registry username. Only valid with Basic.") c.argument('deployment_image_password_setting', options_list=['--deployment-image-password-setting', '--dips'], help="Name of the app setting that stores the registry password. Only valid with Basic. Pass the app setting name, not the password.") - c.argument('deployment_image_server_url', options_list=['--deployment-image-server-url', '--diurl'], - help="Registry server URL for Basic authentication, e.g. `https://myregistry.azurecr.io`. Optional; only valid with Basic.") with self.argument_context('functionapp cors credentials') as c: c.argument('enable', help='enable/disable access-control-allow-credentials', arg_type=get_three_state_flag()) 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 6ff3b0ab910..0b9ccba0166 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -1745,10 +1745,9 @@ def _get_raw_flex_functionapp(cli_ctx, resource_group_name, name): return functionapp -def _build_flex_registry_authentication(auth_type, identity=None, username_setting=None, password_setting=None, - server_url=None): +def _build_flex_registry_authentication(auth_type, identity=None, username_setting=None, password_setting=None): """Return the Registry authentication object for exactly one mode, or None when no auth argument is given.""" - basic_args = (username_setting, password_setting, server_url) + basic_args = (username_setting, password_setting) if auth_type is None: if identity is not None or any(arg is not None for arg in basic_args): raise RequiredArgumentMissingError('--deployment-image-auth-type is required when specifying ' @@ -1758,9 +1757,8 @@ def _build_flex_registry_authentication(auth_type, identity=None, username_setti raise ArgumentUsageError('--deployment-image-identity is only valid with ' '--deployment-image-auth-type UserAssignedIdentity.') if any(arg is not None for arg in basic_args) and auth_type != 'Basic': - raise ArgumentUsageError('--deployment-image-username-setting, --deployment-image-password-setting and ' - '--deployment-image-server-url are only valid with ' - '--deployment-image-auth-type Basic.') + raise ArgumentUsageError('--deployment-image-username-setting and --deployment-image-password-setting ' + 'are only valid with --deployment-image-auth-type Basic.') authentication = {"type": auth_type} if auth_type == 'UserAssignedIdentity': @@ -1775,8 +1773,6 @@ def _build_flex_registry_authentication(auth_type, identity=None, username_setti '--deployment-image-auth-type Basic.') authentication["usernameSettingName"] = username_setting authentication["passwordSettingName"] = password_setting - if server_url is not None: - authentication["serverUrl"] = server_url return authentication @@ -4255,12 +4251,11 @@ def update_deployment_configs(cmd, resource_group_name, name, # pylint: disable deployment_storage_container_name=None, deployment_storage_auth_type=None, deployment_storage_auth_value=None, deployment_image=None, deployment_image_auth_type=None, deployment_image_identity=None, - deployment_image_username_setting=None, deployment_image_password_setting=None, - deployment_image_server_url=None): + deployment_image_username_setting=None, deployment_image_password_setting=None): registry_authentication = _build_flex_registry_authentication( deployment_image_auth_type, deployment_image_identity, deployment_image_username_setting, - deployment_image_password_setting, deployment_image_server_url) + deployment_image_password_setting) if deployment_image is not None or registry_authentication is not None: if any(arg is not None for arg in (deployment_storage_name, deployment_storage_container_name, deployment_storage_auth_type, deployment_storage_auth_value)): @@ -10215,8 +10210,7 @@ def create_functionapp(cmd, resource_group_name, name, storage_account, plan=Non deployment_storage_auth_value=None, zone_redundant=False, configure_networking_later=None, auto_generated_domain_name_label_scope=None, deployment_image=None, deployment_image_auth_type=None, deployment_image_identity=None, - deployment_image_username_setting=None, deployment_image_password_setting=None, - deployment_image_server_url=None): + deployment_image_username_setting=None, deployment_image_password_setting=None): # pylint: disable=too-many-statements, too-many-branches if functions_version is None and flexconsumption_location is None: @@ -10305,7 +10299,7 @@ def create_functionapp(cmd, resource_group_name, name, storage_account, plan=Non registry_authentication = _build_flex_registry_authentication( deployment_image_auth_type, deployment_image_identity, deployment_image_username_setting, - deployment_image_password_setting, deployment_image_server_url) + deployment_image_password_setting) is_flex_registry = deployment_image is not None or registry_authentication is not None if is_flex_registry: if flexconsumption_location is None: 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 cf272d04c7b..63096b143f9 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 @@ -1797,13 +1797,12 @@ def test_functionapp_flex_registry_deployment(self, resource_group, storage_acco JMESPathCheck('storage.value', image), JMESPathCheck('storage.authentication.userAssignedIdentityResourceId', identity['id'])]) self.cmd('functionapp deployment config set -g {} -n {} --deployment-image-auth-type Basic ' - '--deployment-image-username-setting REGISTRY_USERNAME --deployment-image-password-setting REGISTRY_PASSWORD ' - '--deployment-image-server-url https://mcr.microsoft.com'.format(resource_group, uai_app), checks=[ + '--deployment-image-username-setting REGISTRY_USERNAME --deployment-image-password-setting REGISTRY_PASSWORD' + .format(resource_group, uai_app), checks=[ JMESPathCheck('storage.value', image), JMESPathCheck('storage.authentication.type', 'Basic'), JMESPathCheck('storage.authentication.usernameSettingName', 'REGISTRY_USERNAME'), JMESPathCheck('storage.authentication.passwordSettingName', 'REGISTRY_PASSWORD'), - JMESPathCheck('storage.authentication.serverUrl', 'https://mcr.microsoft.com'), JMESPathCheck('storage.authentication.userAssignedIdentityResourceId', None)]) # The service rejects a blank image; the accepted configuration must be unchanged. self.cmd("functionapp deployment config set -g {} -n {} --deployment-image ' '".format(resource_group, uai_app), @@ -1815,13 +1814,11 @@ def test_functionapp_flex_registry_deployment(self, resource_group, storage_acco basic_app = self.create_random_name('functionapp', 40) self.cmd('functionapp create -g {} -n {} -f {} -s {} --deployment-image {} --deployment-image-auth-type Basic ' - '--deployment-image-username-setting REGISTRY_USERNAME --deployment-image-password-setting REGISTRY_PASSWORD ' - '--deployment-image-server-url https://mcr.microsoft.com' + '--deployment-image-username-setting REGISTRY_USERNAME --deployment-image-password-setting REGISTRY_PASSWORD' .format(resource_group, basic_app, FLEX_ASP_LOCATION_FUNCTIONAPP, storage_account, image), checks=[ JMESPathCheck('properties.functionAppConfig.runtime', None), JMESPathCheck(storage + '.authentication.type', 'Basic'), - JMESPathCheck(storage + '.authentication.passwordSettingName', 'REGISTRY_PASSWORD'), - JMESPathCheck(storage + '.authentication.serverUrl', 'https://mcr.microsoft.com')]) + JMESPathCheck(storage + '.authentication.passwordSettingName', 'REGISTRY_PASSWORD')]) tag_digest_image = image + '@sha256:' + 'b' * 64 self.cmd('functionapp deployment config set -g {} -n {} --deployment-image {}' .format(resource_group, basic_app, tag_digest_image)) 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 4a69430ea32..aed47f3d61b 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 @@ -1606,9 +1606,6 @@ def test_set_switches_blob_app_to_registry_with_exact_payload(self): {'deployment_image_auth_type': 'UserAssignedIdentity', 'deployment_image_identity': _REGISTRY_IDENTITY}, {'type': 'UserAssignedIdentity', 'userAssignedIdentityResourceId': _REGISTRY_IDENTITY}), ('Basic', 'registry.contoso.com:5000/team/app:v1', basic_args, basic_auth), - ('Basic with server URL', 'registry.contoso.com:5000/team/app:v1', - dict(basic_args, deployment_image_server_url='https://registry.contoso.com:5000'), - dict(basic_auth, serverUrl='https://registry.contoso.com:5000')), ] for case, image, auth_args, authentication in cases: with self.subTest(case): @@ -1679,8 +1676,6 @@ def test_set_rejects_invalid_registry_arguments_without_writing(self): RequiredArgumentMissingError), ('identity with Basic', _flex_registry_site, dict(basic_args, deployment_image_identity=_REGISTRY_IDENTITY), ArgumentUsageError), - ('server URL without Basic', _flex_registry_site, - dict(image_args, deployment_image_server_url='https://myacr.azurecr.io'), ArgumentUsageError), ('Registry and blob arguments together', _flex_blob_site, dict(image_args, deployment_storage_auth_type='SystemAssignedIdentity'), MutuallyExclusiveArgumentError), ('switch from blob without authentication', _flex_blob_site, @@ -1738,6 +1733,7 @@ def test_other_flex_config_writes_preserve_basic_registry_storage(self): ('update strategy', set_update_strategy_config, {'strategy_type': 'Recreate'}, 'siteUpdateStrategy', 'type', 'Recreate'), ] + # Preserve service-owned fields even when the CLI does not offer an argument to set them. authentication = {'type': 'Basic', 'usernameSettingName': 'REGISTRY_USERNAME', 'passwordSettingName': 'REGISTRY_PASSWORD', 'serverUrl': 'https://myacr.azurecr.io'} for case, update, args, section, field, expected in cases: @@ -1903,15 +1899,13 @@ def test_create_with_basic_authentication_uses_explicit_scale_settings(self): deployment_image='registry.contoso.com/team/app:v1', deployment_image_auth_type='Basic', deployment_image_username_setting='REGISTRY_USERNAME', deployment_image_password_setting='REGISTRY_PASSWORD', - deployment_image_server_url='https://registry.contoso.com', instance_memory=4096, maximum_instance_count=40, always_ready_instances=['http=2'], disable_app_insights='true') self.assertEqual(self._created_site()['properties']['functionAppConfig'], { 'deployment': {'storage': {'type': 'Registry', 'value': 'registry.contoso.com/team/app:v1', 'authentication': {'type': 'Basic', 'usernameSettingName': 'REGISTRY_USERNAME', - 'passwordSettingName': 'REGISTRY_PASSWORD', - 'serverUrl': 'https://registry.contoso.com'}}}, + 'passwordSettingName': 'REGISTRY_PASSWORD'}}}, 'scaleAndConcurrency': {'maximumInstanceCount': 40, 'instanceMemoryMB': 4096, 'alwaysReady': [{'name': 'http', 'instanceCount': 2}]} }) @@ -1940,7 +1934,7 @@ class TestFlexRegistryArgumentParsing(unittest.TestCase): def test_registry_arguments_and_aliases_reach_both_commands(self): from azure.cli.core.mock import DummyCli no_auth_fields = {'deployment_image_identity': None, 'deployment_image_username_setting': None, - 'deployment_image_password_setting': None, 'deployment_image_server_url': None} + 'deployment_image_password_setting': None} cases = [ ('create_functionapp', ['functionapp', 'create', '-g', 'rg', '-n', 'app', '-s', 'sa', '--flexconsumption-location', 'eastus', @@ -1951,11 +1945,10 @@ def test_registry_arguments_and_aliases_reach_both_commands(self): ('update_deployment_configs', ['functionapp', 'deployment', 'config', 'set', '-g', 'rg', '-n', 'app', '--deployment-image', 'myacr.azurecr.io/app:v1', '--diat', 'basic', '--dius', 'REGISTRY_USERNAME', - '--dips', 'REGISTRY_PASSWORD', '--diurl', 'https://myacr.azurecr.io'], + '--dips', 'REGISTRY_PASSWORD'], dict(no_auth_fields, deployment_image='myacr.azurecr.io/app:v1', deployment_image_auth_type='Basic', deployment_image_username_setting='REGISTRY_USERNAME', - deployment_image_password_setting='REGISTRY_PASSWORD', - deployment_image_server_url='https://myacr.azurecr.io')), + deployment_image_password_setting='REGISTRY_PASSWORD')), ] for handler, args, expected in cases: with self.subTest(handler), \ @@ -1965,3 +1958,8 @@ def test_registry_arguments_and_aliases_reach_both_commands(self): self.assertEqual(DummyCli().invoke(args, out_file=io.StringIO()), 0) received = handler_mock.call_args.kwargs self.assertEqual({key: received[key] for key in expected}, expected) + for flag in ('--deployment-image-server-url', '--diurl'): + with self.subTest(handler=handler, flag=flag), self.assertRaises(SystemExit) as error: + DummyCli().invoke(args + [flag, 'https://myacr.azurecr.io'], out_file=io.StringIO()) + self.assertEqual(error.exception.code, 2) + handler_mock.assert_called_once()