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..d59dd637179 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. +FLEX_DEFAULT_MAXIMUM_INSTANCE_COUNT = 1000 +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..6d3a1a4ede0 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -596,16 +596,37 @@ 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. An existing Registry app may update just the image or authentication, but both must be + present in the resulting configuration. 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 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 """ 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 @@ -624,6 +645,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 @@ -766,7 +790,9 @@ helps['functionapp create'] = """ type: command short-summary: Create a function app. -long-summary: The function app's name must be able to produce a unique FQDN as AppName.azurewebsites.net. +long-summary: > + The function app's name must be able to produce a unique FQDN as AppName.azurewebsites.net. + Flex Consumption apps created with --deployment-image use the Recreate site update strategy by default. examples: - name: Create a basic function app. text: > @@ -780,6 +806,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 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 """ 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..4422a19bb0b 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,21 @@ 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.") + 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..fb785a8a2d9 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,76 @@ 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 _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 _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) + 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 and --deployment-image-password-setting ' + '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 + 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) + }, + "siteUpdateStrategy": { + "type": "Recreate" + } + } + + 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') @@ -3745,6 +3813,15 @@ 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 _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) + 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') @@ -3778,9 +3855,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) @@ -3834,8 +3909,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) @@ -4160,7 +4234,20 @@ 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): + + registry_authentication = _build_flex_registry_authentication( + deployment_image_auth_type, deployment_image_identity, deployment_image_username_setting, + 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)): + 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 +4268,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 +4346,31 @@ 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 not None: + storage["authentication"] = authentication + + if not storage.get("value"): + raise RequiredArgumentMissingError('Registry deployment storage requires a non-empty image. ' + 'Specify --deployment-image.') + if not (storage.get("authentication") or {}).get("type"): + raise RequiredArgumentMissingError('Registry deployment storage requires an authentication type. ' + 'Specify --deployment-image-auth-type.') + + result = _update_flex_functionapp_config(cmd, resource_group_name, name, functionapp) + 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,17 +4572,35 @@ 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) 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): + _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) + + def delete_always_ready_settings(cmd, resource_group_name, name, setting_names): functionapp = get_raw_functionapp(cmd.cli_ctx, resource_group_name, name) @@ -4477,7 +4610,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", {}) @@ -4492,6 +4625,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'] @@ -4535,7 +4672,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", {}) @@ -4575,7 +4712,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", {}) @@ -4613,7 +4750,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", {}) @@ -10063,7 +10200,9 @@ 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): # pylint: disable=too-many-statements, too-many-branches if functions_version is None and flexconsumption_location is None: @@ -10150,6 +10289,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) + 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 +10444,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 +10638,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 +10674,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 +10740,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 +10749,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..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 @@ -1767,6 +1767,79 @@ 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): + # 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 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.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' + .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.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 ' + '--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')]) + 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' + .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() 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..5b3eb587c86 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,20 @@ _build_flex_function_app_config, _prepare_flex_deployment_storage, _prepare_flex_deployment_storage_identity, - revert_flex_migration) + 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, + update_deployment_configs, + update_runtime_config, + update_scale_config) 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 +1452,574 @@ 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', + 'serverFarmId': '/subscriptions/{}/resourceGroups/rg/providers/Microsoft.Web/serverfarms/plan'.format( + _REGISTRY_SUBSCRIPTION), + '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 _non_flex_site(): + site = _flex_blob_site() + site['properties']['sku'] = 'Dynamic' + del site['properties']['functionAppConfig'] + return site + + +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.raw = mock.Mock() + response.raw.stream.return_value = iter([response._content]) # 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` and `functionapp 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), + ] + 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, '2023-12-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_preserves_registry_configuration_and_keeps_blob_requests_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'], _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'], 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_show_keeps_non_flex_apps_and_slots_on_the_existing_sdk_path(self): + for slot in (None, 'staging'): + with self.subTest(slot=slot): + app, config = mock.Mock(), mock.Mock() + with _fake_arm(_non_flex_site()) as arm, \ + mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation', + side_effect=[app, config]) as operation, \ + mock.patch('azure.cli.command_modules.appservice.custom.is_centauri_functionapp', + return_value=False), \ + mock.patch('azure.cli.command_modules.appservice.custom._rename_server_farm_props') as rename, \ + mock.patch('azure.cli.command_modules.appservice.custom._fill_ftp_publishing_url') as fill_ftp: + result = show_functionapp(self.cmd, 'rg', 'app', slot) + + self.assertEqual(arm.calls(), [('GET', _REGISTRY_SITE, '2023-12-01')]) + self.assertEqual(operation.call_args_list, [ + mock.call(self.cmd.cli_ctx, 'rg', 'app', 'get', slot), + mock.call(self.cmd.cli_ctx, 'rg', 'app', 'get_configuration', slot)]) + self.assertIs(result, app) + self.assertIs(app.site_config, config) + rename.assert_called_once_with(app) + fill_ftp.assert_called_once_with(self.cmd, app, 'rg', 'app', slot) + + 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 image', _flex_registry_site, + {'deployment_image': ''}, 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), + ('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_requires_nonempty_image_and_authentication_on_existing_registry_apps(self): + cases = [ + ('value', {'deployment_image_auth_type': 'Anonymous'}, '--deployment-image', + [mock.sentinel.missing, None, '']), + ('authentication', {'deployment_image': 'myacr.azurecr.io/app:v2'}, '--deployment-image-auth-type', + [mock.sentinel.missing, None, {}, {'type': None}, {'type': ''}]), + ] + for field, args, required_argument, invalid_values in cases: + for value in invalid_values: + with self.subTest(field=field, value=value): + site = _flex_registry_site() + storage = site['properties']['functionAppConfig']['deployment']['storage'] + if value is mock.sentinel.missing: + del storage[field] + else: + storage[field] = value + with _fake_arm(site) as arm: + with self.assertRaisesRegex(RequiredArgumentMissingError, required_argument): + update_deployment_configs(self.cmd, 'rg', 'app', **args) + self.assertNotIn('PUT', [method for method, _, _ in arm.requests]) + + def test_set_can_repair_registry_storage_with_missing_image_and_authentication(self): + site = _flex_registry_site() + site['properties']['functionAppConfig']['deployment']['storage'].update(value=None, authentication=None) + storage = {'type': 'Registry', 'value': 'myacr.azurecr.io/app:v2', 'authentication': {'type': 'Anonymous'}} + with _fake_arm(site) as arm: + result = update_deployment_configs(self.cmd, 'rg', 'app', deployment_image=storage['value'], + deployment_image_auth_type='Anonymous') + + self.assertEqual([method for method, _, _ in arm.requests], ['GET', 'PUT']) + self.assertEqual(arm.requests[-1][2], _with_registry_storage(site, storage)) + self.assertEqual(result, {'storage': storage}) + + 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_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', '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_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: + 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.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'), + ] + # 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: + 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'), + ('PUT', _REGISTRY_SITE, '2025-05-01'), + ('GET', _REGISTRY_SITE, '2023-12-01')]) + sent = arm.requests[1][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 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', '2023-12-01']), + ('Blob', _flex_blob_site, ['2023-12-01', '2023-12-01']), + ('Non-Flex', _non_flex_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) + if storage_type == 'Non-Flex': + self.assertNotIn('functionAppConfig', sent['properties']) + continue + 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.""" + + 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': 1000, 'instanceMemoryMB': 2048, 'alwaysReady': []}, + 'siteUpdateStrategy': {'type': 'Recreate'} + }) + # 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() + 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', + 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'}}}, + 'scaleAndConcurrency': {'maximumInstanceCount': 40, 'instanceMemoryMB': 4096, + 'alwaysReady': [{'name': 'http', 'instanceCount': 2}]}, + 'siteUpdateStrategy': {'type': 'Recreate'} + }) + 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} + 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'], + 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')), + ] + 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) + 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() 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()