diff --git a/src/functionapp/HISTORY.rst b/src/functionapp/HISTORY.rst index b538dfa6340..869c01e17f9 100644 --- a/src/functionapp/HISTORY.rst +++ b/src/functionapp/HISTORY.rst @@ -3,6 +3,10 @@ Release History =============== +0.1.2 +++++++ +* Fix `az functionapp deployment source config-zip`: zip file contents were not uploaded correctly (path string was uploaded instead of file content) for Linux consumption plan function apps. + 0.1.1 ++++++ * Fix bug when running `az functionapp devops-pipeline create` diff --git a/src/functionapp/azext_functionapp/_params.py b/src/functionapp/azext_functionapp/_params.py index ac5e7cabc84..8f971ba7fc4 100644 --- a/src/functionapp/azext_functionapp/_params.py +++ b/src/functionapp/azext_functionapp/_params.py @@ -29,3 +29,16 @@ def load_arguments(self, _): required=False) c.argument('github_repository', help="Fullname of your Github repository (e.g. Azure/azure-cli)", required=False) + + with self.argument_context('functionapp deployment source config-zip') as c: + c.argument('name', options_list=['--name', '-n'], help='Name of the function app') + c.argument('resource_group_name', options_list=['--resource-group', '-g'], + help='Name of the resource group') + c.argument('src', options_list=['--src', '-s'], help='A zip file path for deployment') + c.argument('build_remote', options_list=['--build-remote'], + help='Enable remote build during deployment', + arg_type=get_three_state_flag(return_label=True)) + c.argument('timeout', type=int, options_list=['--timeout', '-t'], + help='Configurable timeout in seconds for checking the status of deployment') + c.argument('slot', options_list=['--slot'], + help='The name of the slot. Defaults to the productions slot if not specified') diff --git a/src/functionapp/azext_functionapp/commands.py b/src/functionapp/azext_functionapp/commands.py index 7450c882a58..31ad0228e3f 100644 --- a/src/functionapp/azext_functionapp/commands.py +++ b/src/functionapp/azext_functionapp/commands.py @@ -9,3 +9,6 @@ def load_command_table(self, _): with self.command_group('functionapp devops-pipeline') as g: g.custom_command('create', 'create_devops_pipeline') + + with self.command_group('functionapp deployment source') as g: + g.custom_command('config-zip', 'enable_zip_deploy_functionapp') diff --git a/src/functionapp/azext_functionapp/custom.py b/src/functionapp/azext_functionapp/custom.py index 726b60a8d8a..10509c02be4 100644 --- a/src/functionapp/azext_functionapp/custom.py +++ b/src/functionapp/azext_functionapp/custom.py @@ -3,11 +3,141 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +import time + from knack.log import get_logger logger = get_logger(__name__) +def enable_zip_deploy_functionapp(cmd, resource_group_name, name, src, build_remote=False, timeout=None, slot=None): + from azure.cli.command_modules.appservice.custom import ( + web_client_factory, + is_plan_consumption, + enable_zip_deploy, + add_remote_build_app_settings, + remove_remote_build_app_settings, + ) + from azure.cli.core.azclierror import ResourceNotFoundError + from azure.mgmt.core.tools import parse_resource_id + + client = web_client_factory(cmd.cli_ctx) + app = client.web_apps.get(resource_group_name, name) + if app is None: + raise ResourceNotFoundError( + "The function app '{}' was not found in resource group '{}'. " + "Please make sure these values are correct.".format(name, resource_group_name)) + + parse_plan_id = parse_resource_id(app.server_farm_id) + plan_info = None + retry_delay = 10 # seconds + # We need to retry getting the plan because sometimes if the plan is created as part of function app, + # it can take a couple of tries before it gets the plan + for _ in range(5): + try: + plan_info = client.app_service_plans.get(parse_plan_id['resource_group'], + parse_plan_id['name']) + except Exception: # pylint: disable=broad-except + pass + if plan_info is not None: + break + time.sleep(retry_delay) + + is_consumption = is_plan_consumption(cmd, plan_info) + + # Handle flex function apps if the core CLI supports it + try: + from azure.cli.command_modules.appservice.custom import ( + is_flex_functionapp, + enable_zip_deploy_flex, + check_flex_app_after_deployment, + ) + if is_flex_functionapp(cmd.cli_ctx, resource_group_name, name): + enable_zip_deploy_flex(cmd, resource_group_name, name, src, timeout, slot, build_remote) + return check_flex_app_after_deployment(cmd, resource_group_name, name) + except ImportError: + pass + + build_remote = build_remote is True or build_remote == 'true' + if (not build_remote) and is_consumption and app.reserved: + return _upload_zip_to_storage(cmd, resource_group_name, name, src, slot) + if build_remote and app.reserved: + add_remote_build_app_settings(cmd, resource_group_name, name, slot) + elif app.reserved: + remove_remote_build_app_settings(cmd, resource_group_name, name, slot) + + return enable_zip_deploy(cmd, resource_group_name, name, src, timeout, slot) + + +def _upload_zip_to_storage(cmd, resource_group_name, name, src, slot=None): + import datetime + import os + import uuid + from azure.cli.command_modules.appservice.custom import ( + get_app_settings, + update_app_settings, + web_client_factory, + ) + from azure.cli.core.profiles import ResourceType, get_sdk + + settings = get_app_settings(cmd, resource_group_name, name, slot) + storage_connection = None + for keyval in settings: + if keyval['name'] == 'AzureWebJobsStorage': + storage_connection = str(keyval['value']) + + container_name = "function-releases" + blob_name = "{}-{}.zip".format(datetime.datetime.today().strftime('%Y%m%d%H%M%S'), str(uuid.uuid4())) + BlobServiceClient = get_sdk(cmd.cli_ctx, ResourceType.DATA_STORAGE_BLOB, + '_blob_service_client#BlobServiceClient') + blob_service_client = BlobServiceClient.from_connection_string(conn_str=storage_connection) + container_client = blob_service_client.get_container_client(container_name) + if not container_client.exists(): + container_client.create_container() + + # https://gist.github.com/vladignatyev/06860ec2040cb497f0f3 + def progress_callback(current, total): + total_length = 30 + filled_length = int(round(total_length * current) / float(total)) + percents = round(100.0 * current / float(total), 1) + progress_bar = '=' * filled_length + '-' * (total_length - filled_length) + progress_message = 'Uploading {} {}%'.format(progress_bar, percents) + cmd.cli_ctx.get_progress_controller().add(message=progress_message) + + blob_client = None + with open(os.path.realpath(os.path.expanduser(src)), 'rb') as fs: + zip_content = fs.read() + blob_client = container_client.upload_blob(blob_name, zip_content, validate_content=True, + progress_hook=progress_callback) + + now = datetime.datetime.utcnow() + blob_start = now - datetime.timedelta(minutes=10) + blob_end = now + datetime.timedelta(weeks=520) + BlobSharedAccessSignature = get_sdk(cmd.cli_ctx, ResourceType.DATA_STORAGE_BLOB, + '_shared_access_signature#BlobSharedAccessSignature') + BlobSasPermissions = get_sdk(cmd.cli_ctx, ResourceType.DATA_STORAGE_BLOB, '_models#BlobSasPermissions') + sas_client = BlobSharedAccessSignature(blob_service_client.account_name, + account_key=blob_service_client.credential.account_key) + blob_token = sas_client.generate_blob(container_name, blob_name, permission=BlobSasPermissions(read=True), + expiry=blob_end, start=blob_start) + + blob_uri = blob_client.url + if '?' not in blob_uri: + blob_uri += '?' + blob_token + website_run_from_setting = "WEBSITE_RUN_FROM_PACKAGE={}".format(blob_uri) + update_app_settings(cmd, resource_group_name, name, settings=[website_run_from_setting], slot=slot) + + client = web_client_factory(cmd.cli_ctx) + try: + logger.info('\nSyncing Triggers...') + if slot is not None: + client.web_apps.sync_function_triggers_slot(resource_group_name, name, slot) + else: + client.web_apps.sync_function_triggers(resource_group_name, name) + except Exception as ex: # pylint: disable=broad-except + logger.warning('\nWarning: Unable to sync triggers. %s', ex) + + def create_devops_pipeline( cmd, functionapp_name=None, diff --git a/src/functionapp/azext_functionapp/tests/latest/test_zip_deploy_unit.py b/src/functionapp/azext_functionapp/tests/latest/test_zip_deploy_unit.py new file mode 100644 index 00000000000..a520b175d6f --- /dev/null +++ b/src/functionapp/azext_functionapp/tests/latest/test_zip_deploy_unit.py @@ -0,0 +1,149 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import ast +import os +import sys +import tempfile +import types +import unittest +from unittest.mock import MagicMock, patch + + +# Extract _upload_zip_to_storage from custom.py using ast/exec to avoid +# importing the full Azure CLI runtime. +_CUSTOM_PY = os.path.join(os.path.dirname(__file__), '..', '..', 'custom.py') + +_ns = { + '__name__': 'azext_functionapp.custom', + 'logger': MagicMock(), + 'time': __import__('time'), +} + +with open(_CUSTOM_PY) as _f: + _tree = ast.parse(_f.read()) + +for _node in _tree.body: + if isinstance(_node, ast.FunctionDef) and _node.name in ('_upload_zip_to_storage',): + exec( # pylint: disable=exec-used + compile(ast.Module(body=[_node], type_ignores=[]), _CUSTOM_PY, 'exec'), + _ns) + +_upload_zip_to_storage = _ns['_upload_zip_to_storage'] + + +class TestUploadZipToStorage(unittest.TestCase): + """Unit tests for _upload_zip_to_storage. + + These tests guard against the regression described in + azure-cli-extensions#10024 / azure-cli#32044 where the zip file path + string was uploaded as the blob content instead of the actual file bytes. + """ + + def _make_mocks(self): + """Build the minimum set of mocks required to exercise _upload_zip_to_storage.""" + uploaded = {} + + def fake_upload_blob(blob_name, data, validate_content=False, progress_hook=None): + uploaded['data'] = data + mock_bc = MagicMock() + mock_bc.url = 'https://fake.blob.core.windows.net/function-releases/' + blob_name + return mock_bc + + mock_container = MagicMock() + mock_container.exists.return_value = True + mock_container.upload_blob.side_effect = fake_upload_blob + + mock_blob_svc = MagicMock() + mock_blob_svc.account_name = 'fakeaccount' + mock_blob_svc.credential.account_key = 'fakekey==' + mock_blob_svc.get_container_client.return_value = mock_container + + mock_sas = MagicMock() + mock_sas.generate_blob.return_value = 'se=2030&sp=r&sig=abc' + + mock_cmd = MagicMock() + mock_cmd.cli_ctx.get_progress_controller.return_value = MagicMock() + + fake_appservice = types.ModuleType('azure.cli.command_modules.appservice.custom') + fake_appservice.get_app_settings = MagicMock(return_value=[ + {'name': 'AzureWebJobsStorage', + 'value': 'DefaultEndpointsProtocol=https;AccountName=fakeaccount'} + ]) + fake_appservice.update_app_settings = MagicMock() + fake_appservice.web_client_factory = MagicMock(return_value=MagicMock()) + + def fake_get_sdk(_cli_ctx, _resource_type, path): + if 'BlobServiceClient' in path: + cls = MagicMock() + cls.from_connection_string.return_value = mock_blob_svc + return cls + if 'BlobSharedAccessSignature' in path: + return MagicMock(return_value=mock_sas) + return MagicMock() + + fake_profiles = types.ModuleType('azure.cli.core.profiles') + fake_profiles.ResourceType = MagicMock(DATA_STORAGE_BLOB='DATA_STORAGE_BLOB') + fake_profiles.get_sdk = fake_get_sdk + + return mock_cmd, fake_appservice, fake_profiles, uploaded + + def test_upload_reads_file_content_not_path_string(self): + """_upload_zip_to_storage must upload the zip file's binary content, + not the path string (regression test for azure-cli-extensions#10024).""" + zip_bytes = b'PK\x03\x04' + b'\x00' * 26 # minimal ZIP local file header + + with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp: + tmp.write(zip_bytes) + tmp_path = tmp.name + + try: + mock_cmd, fake_appservice, fake_profiles, uploaded = self._make_mocks() + + with patch.dict(sys.modules, { + 'azure.cli.command_modules.appservice.custom': fake_appservice, + 'azure.cli.core.profiles': fake_profiles, + }): + _upload_zip_to_storage(mock_cmd, 'my-rg', 'my-func', tmp_path) + + # The blob must contain the actual zip bytes, not the path string. + self.assertIn('data', uploaded, + "upload_blob() was never called") + self.assertEqual(uploaded['data'], zip_bytes, + "upload_blob() should receive the zip file content, " + "not the path string (fix for azure-cli-extensions#10024)") + finally: + os.unlink(tmp_path) + + def test_upload_does_not_pass_path_string_as_blob_content(self): + """Explicitly verify that the src path string is NOT passed as blob data.""" + zip_bytes = b'PK\x03\x04fake zip content' + + with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp: + tmp.write(zip_bytes) + tmp_path = tmp.name + + try: + mock_cmd, fake_appservice, fake_profiles, uploaded = self._make_mocks() + + with patch.dict(sys.modules, { + 'azure.cli.command_modules.appservice.custom': fake_appservice, + 'azure.cli.core.profiles': fake_profiles, + }): + _upload_zip_to_storage(mock_cmd, 'my-rg', 'my-func', tmp_path) + + # The blob must NOT be the raw path string encoded as bytes. + self.assertNotEqual( + uploaded.get('data'), tmp_path.encode(), + "upload_blob() must NOT receive the file path as blob content") + self.assertNotEqual( + uploaded.get('data'), tmp_path, + "upload_blob() must NOT receive the file path string as blob content") + finally: + os.unlink(tmp_path) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/functionapp/setup.py b/src/functionapp/setup.py index 0ef17b1ce8b..63c986a414d 100644 --- a/src/functionapp/setup.py +++ b/src/functionapp/setup.py @@ -16,7 +16,7 @@ # TODO: Confirm this is the right version number you want and it matches your # HISTORY.rst entry. -VERSION = '0.1.1' +VERSION = '0.1.2' # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers