Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/functionapp/HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
13 changes: 13 additions & 0 deletions src/functionapp/azext_functionapp/_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
3 changes: 3 additions & 0 deletions src/functionapp/azext_functionapp/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
130 changes: 130 additions & 0 deletions src/functionapp/azext_functionapp/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
149 changes: 149 additions & 0 deletions src/functionapp/azext_functionapp/tests/latest/test_zip_deploy_unit.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 1 addition & 1 deletion src/functionapp/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading