Skip to content
Merged
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
89 changes: 79 additions & 10 deletions src/azure-cli/azure/cli/command_modules/resource/_bicep.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,49 @@
import stat
import platform
import subprocess
import json

from pathlib import Path
from json.decoder import JSONDecodeError
from contextlib import suppress
from datetime import datetime, timedelta

import requests
import semver

from six.moves.urllib.request import urlopen
from knack.log import get_logger
from azure.cli.core.azclierror import FileOperationError, ValidationError, UnclassifiedUserFault, ClientRequestError
from azure.cli.core.api import get_config_dir
from azure.cli.core.azclierror import (
FileOperationError,
ValidationError,
UnclassifiedUserFault,
ClientRequestError,
InvalidTemplateError,
)

# See: https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
_semver_pattern = r"(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?" # pylint: disable=line-too-long

# See: https://docs.microsoft.com/en-us/azure/azure-resource-manager/templates/template-syntax#template-format
_template_schema_pattern = r"https?://schema\.management\.azure\.com/schemas/[0-9a-zA-Z-]+/(?P<templateType>[a-zA-Z]+)Template\.json#?" # pylint: disable=line-too-long

_config_dir = get_config_dir()
_bicep_installation_dir = os.path.join(_config_dir, "bin")
_bicep_version_check_file_path = os.path.join(_config_dir, "bicepVersionCheck.json")
_bicep_version_check_cache_ttl = timedelta(minutes=10)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

May I ask why the ttl of this cache is 10 minutes?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting ttl to 10 minutes means it will sent at most 6 requests per hour, which is good enough to avoid GitHub throttling for anonymous requests (60 / hour / IP address). Other other hand, we do want the bicep commands to detect version upgrade of Bicep CLI as soon as we do a release, and I feel like 10 minute of delay is acceptable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is still a workaround, though. CI/CD runs that depend on Bicep can still get throttled. We are also thinking about setting up CDN for caching the Bicep releases to completely eliminate the throttling issue.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, thanks~


_logger = get_logger(__name__)


def run_bicep_command(args, auto_install=True, check_upgrade=True):
def validate_bicep_target_scope(template_schema, deployment_scope):
target_scope = _template_schema_to_target_scope(template_schema)
if target_scope != deployment_scope:
raise InvalidTemplateError(
f'The target scope "{target_scope}" does not match the deployment scope "{deployment_scope}".'
)


def run_bicep_command(args, auto_install=True, check_version=True):
installation_path = _get_bicep_installation_path(platform.system())
installed = os.path.isfile(installation_path)

Expand All @@ -33,19 +59,25 @@ def run_bicep_command(args, auto_install=True, check_upgrade=True):
ensure_bicep_installation(stdout=False)
else:
raise FileOperationError('Bicep CLI not found. Install it now by running "az bicep install".')
elif check_upgrade:
elif check_version:
latest_release_tag, cache_expired = _load_bicep_version_check_result_from_cache()

with suppress(ClientRequestError):
# Checking upgrade should ignore connection issues.
# Users may continue using the current installed version.
installed_version = _get_bicep_installed_version(installation_path)
latest_release_tag = get_bicep_latest_release_tag()
latest_release_tag = get_bicep_latest_release_tag() if cache_expired else latest_release_tag
latest_version = _extract_semver(latest_release_tag)

if installed_version and latest_version and semver.compare(installed_version, latest_version) < 0:
_logger.warning(
'A new Bicep release is available: %s. Upgrade now by running "az bicep upgrade".',
latest_release_tag,
)

if cache_expired:
_refresh_bicep_version_check_cache(latest_release_tag)

return _run_command(installation_path, args)


Expand Down Expand Up @@ -84,7 +116,7 @@ def ensure_bicep_installation(release_tag=None, stdout=True):
print(f'Successfully installed Bicep CLI to "{installation_path}".')
else:
_logger.info(
'Successfully installed Bicep CLI to %s',
"Successfully installed Bicep CLI to %s",
installation_path,
)
except IOError as err:
Expand All @@ -106,11 +138,34 @@ def get_bicep_available_release_tags():
def get_bicep_latest_release_tag():
try:
response = requests.get("https://api.github.com/repos/Azure/bicep/releases/latest")
response.raise_for_status()
return response.json()["tag_name"]
except IOError as err:
raise ClientRequestError(f"Error while attempting to retrieve the latest Bicep version: {err}.")


def _load_bicep_version_check_result_from_cache():
try:
with open(_bicep_version_check_file_path, "r") as version_check_file:
version_check_data = json.load(version_check_file)
latest_release_tag = version_check_data["latestReleaseTag"]
last_check_time = datetime.fromisoformat(version_check_data["lastCheckTime"])
cache_expired = datetime.now() - last_check_time > _bicep_version_check_cache_ttl

return latest_release_tag, cache_expired
except (IOError, JSONDecodeError):
return None, True


def _refresh_bicep_version_check_cache(lastest_release_tag):
with open(_bicep_version_check_file_path, "w+") as version_check_file:
version_check_data = {
"lastCheckTime": datetime.now().isoformat(timespec="microseconds"),
"latestReleaseTag": lastest_release_tag,
}
json.dump(version_check_data, version_check_file)


def _get_bicep_installed_version(bicep_executable_path):
installed_version_output = _run_command(bicep_executable_path, ["--version"])
return _extract_semver(installed_version_output)
Expand All @@ -132,12 +187,10 @@ def _get_bicep_download_url(system, release_tag):


def _get_bicep_installation_path(system):
installation_folder = os.path.join(str(Path.home()), ".azure", "bin")

if system == "Windows":
return os.path.join(installation_folder, "bicep.exe")
return os.path.join(_bicep_installation_dir, "bicep.exe")
if system in ("Linux", "Darwin"):
return os.path.join(installation_folder, "bicep")
return os.path.join(_bicep_installation_dir, "bicep")

raise ValidationError(f'The platform "{format(system)}" is not supported.')

Expand All @@ -158,3 +211,19 @@ def _run_command(bicep_installation_path, args):
return process.stdout.decode("utf-8")
except subprocess.CalledProcessError:
raise UnclassifiedUserFault(process.stderr.decode("utf-8"))


def _template_schema_to_target_scope(template_schema):
template_schema_match = re.search(_template_schema_pattern, template_schema)
template_type = template_schema_match.group("templateType") if template_schema_match else None
template_type_lower = template_type.lower() if template_type else None

if template_type_lower == "deployment":
return "resourceGroup"
if template_type_lower == "subscriptiondeployment":
return "subscription"
if template_type_lower == "managementgroupdeployment":
return "managementGroup"
if template_type_lower == "tenantdeployment":
return "tenant"
return None
22 changes: 10 additions & 12 deletions src/azure-cli/azure/cli/command_modules/resource/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -2334,26 +2334,24 @@

helps['bicep build'] = """
type: command
short-summary: Build one or more Bicep files.
short-summary: Build a Bicep file.
examples:
- name: Build a Bicep file.
text: az bicep build --files {bicep_file}
- name: Build multiple Bicep files.
text: az bicep build --files {bicep_file1} {bicep_file2}
- name: Build a Bicep file and prints all output to stdout.
text: az bicep build --files {bicep_file} --stdout
- name: Build multiple Bicep files and prints all output to stdout.
text: az bicep build --files {bicep_file1} {bicep_file2} --stdout
text: az bicep build --file {bicep_file}
- name: Build a Bicep file and print all output to stdout.
text: az bicep build --file {bicep_file} --stdout
- name: Build a Bicep file and save the result to the specified directory.
text: az bicep build --file {bicep_file} --outdir {out_dir}
- name: Build a Bicep file and save the result to the specified file.
text: az bicep build --file {bicep_file} --outfile {out_file}
"""

helps['bicep decompile'] = """
type: command
short-summary: Attempt to decompile one or more ARM template files to Bicep files
short-summary: Attempt to decompile an ARM template file to a Bicep file.
examples:
- name: Decompile an ARM template file.
text: az bicep decompile --files {json_template_file}
- name: Decompile multiple ARM template files.
text: az bicep decompile --files {json_template_file1} {json_template_file2}
text: az bicep decompile --file {json_template_file}
"""

helps['bicep version'] = """
Expand Down
13 changes: 9 additions & 4 deletions src/azure-cli/azure/cli/command_modules/resource/_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
# pylint: disable=too-many-locals, too-many-statements, line-too-long
def load_arguments(self, _):
from argcomplete.completers import FilesCompleter
from argcomplete.completers import DirectoriesCompleter

from azure.mgmt.resource.locks.models import LockLevel
from azure.mgmt.resource.managedapplications.models import ApplicationLockLevel
Expand Down Expand Up @@ -572,14 +573,18 @@ def load_arguments(self, _):
c.argument('resource_group', arg_type=resource_group_name_type)

with self.argument_context('bicep build') as c:
c.argument('files', arg_type=CLIArgumentType(nargs="+", options_list=['--files', '-f'], completer=FilesCompleter(),
type=file_type, help="Space separated Bicep file paths in the file system."))
c.argument('file', arg_type=CLIArgumentType(options_list=['--file', '-f'], completer=FilesCompleter(),
type=file_type, help="The path to the Bicep file to build in the file system."))
c.argument('outdir', arg_type=CLIArgumentType(options_list=['--outdir'], completer=DirectoriesCompleter(),
help="When set, saves the output at the specified directory."))
c.argument('outfile', arg_type=CLIArgumentType(options_list=['--outfile'], completer=FilesCompleter(),
help="When set, saves the output as the specified file path."))
c.argument('stdout', arg_type=CLIArgumentType(options_list=['--stdout'], action='store_true',
help="When set, prints all output to stdout instead of corresponding files."))

with self.argument_context('bicep decompile') as c:
c.argument('files', arg_type=CLIArgumentType(nargs="+", options_list=['--files', '-f'], completer=FilesCompleter(),
type=file_type, help="Space separated ARM template paths in the file system."))
c.argument('file', arg_type=CLIArgumentType(options_list=['--file', '-f'], completer=FilesCompleter(),
type=file_type, help="The path to the ARM template to decompile in the file system."))

with self.argument_context('bicep install') as c:
c.argument('version', options_list=['--version', '-v'], help='The version of Bicep CLI to be installed. Default to the latest if not specified.')
Loading