diff --git a/src/azure-cli/azure/cli/command_modules/acr/_help.py b/src/azure-cli/azure/cli/command_modules/acr/_help.py index 5acb90af949..4f694fecd73 100644 --- a/src/azure-cli/azure/cli/command_modules/acr/_help.py +++ b/src/azure-cli/azure/cli/command_modules/acr/_help.py @@ -226,6 +226,24 @@ az acr helm show -n MyRegistry mychart --version 0.3.2 """ +helps['acr helm install-cli'] = """ +type: command +short-summary: Download and install Helm command-line tool. +examples: + - name: Install the default version of Helm CLI to the default location + text: > + az acr helm install-cli + - name: Install a specified version of Helm CLI to the default location + text: > + az acr helm install-cli --client-version x.x.x + - name: Install the default version of Helm CLI to a specified location + text: > + az acr helm install-cli --install-location /folder/filename + - name: Install a specified version of Helm CLI to a specified location + text: > + az acr helm install-cli --client-version x.x.x --install-location /folder/filename +""" + helps['acr import'] = """ type: command short-summary: Imports an image to an Azure Container Registry from another Container Registry. Import removes the need to docker pull, docker tag, docker push. diff --git a/src/azure-cli/azure/cli/command_modules/acr/_params.py b/src/azure-cli/azure/cli/command_modules/acr/_params.py index 7b49e6e21ec..31ae6b697c6 100644 --- a/src/azure-cli/azure/cli/command_modules/acr/_params.py +++ b/src/azure-cli/azure/cli/command_modules/acr/_params.py @@ -5,6 +5,9 @@ # pylint: disable=line-too-long import argparse +import os.path +import platform + from argcomplete.completers import FilesCompleter from knack.arguments import CLIArgumentType @@ -258,6 +261,11 @@ def load_arguments(self, _): # pylint: disable=too-many-statements c.positional('chart_package', help="The helm chart package.", completer=FilesCompleter()) c.argument('force', help='Overwrite the existing chart package.', action='store_true') + with self.argument_context('acr helm install-cli') as c: + c.argument('client_version', help='The target Helm CLI version. (Attention: Currently, Helm 3 does not work with "az acr helm" commands) ') + c.argument('install_location', help='Path at which to install Helm CLI (Existing one at the same path will be overwritten)', default=_get_helm_default_install_location()) + c.argument('yes', help='Agree to the license of Helm, and do not prompt for confirmation.') + with self.argument_context('acr network-rule') as c: c.argument('subnet', help='Name or ID of subnet. If name is supplied, `--vnet-name` must be supplied.') c.argument('vnet_name', help='Name of a virtual network.') @@ -307,3 +315,18 @@ def load_arguments(self, _): # pylint: disable=too-many-statements with self.argument_context('acr token credential delete') as c: c.argument('password1', options_list=['--password1'], help='Flag indicating if first password should be deleted', action='store_true', required=False) c.argument('password2', options_list=['--password2'], help='Flag indicating if second password should be deleted.', action='store_true', required=False) + + +def _get_helm_default_install_location(): + exe_name = 'helm' + system = platform.system() + if system == 'Windows': + home_dir = os.environ.get('USERPROFILE') + if not home_dir: + return None + install_location = os.path.join(home_dir, r'.azure-{0}\{0}.exe'.format(exe_name)) + elif system in ('Linux', 'Darwin'): + install_location = '/usr/local/bin/{}'.format(exe_name) + else: + install_location = None + return install_location diff --git a/src/azure-cli/azure/cli/command_modules/acr/commands.py b/src/azure-cli/azure/cli/command_modules/acr/commands.py index 4920bbef4fd..a41d3e878cc 100644 --- a/src/azure-cli/azure/cli/command_modules/acr/commands.py +++ b/src/azure-cli/azure/cli/command_modules/acr/commands.py @@ -267,6 +267,7 @@ def load_command_table(self, _): # pylint: disable=too-many-statements g.command('delete', 'acr_helm_delete') g.command('push', 'acr_helm_push') g.command('repo add', 'acr_helm_repo_add') + g.command('install-cli', 'acr_helm_install_cli', is_preview=True) with self.command_group('acr network-rule', acr_network_rule_util) as g: g.command('list', 'acr_network_rule_list') diff --git a/src/azure-cli/azure/cli/command_modules/acr/helm.py b/src/azure-cli/azure/cli/command_modules/acr/helm.py index dcdd58891ab..fd6ff3ab20c 100644 --- a/src/azure-cli/azure/cli/command_modules/acr/helm.py +++ b/src/azure-cli/azure/cli/command_modules/acr/helm.py @@ -3,10 +3,17 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +import os +import platform +from six.moves.urllib.request import urlopen # pylint: disable=import-error + from knack.util import CLIError from knack.log import get_logger +from azure.cli.core.util import in_cloud_console + from ._utils import user_confirmation + from ._docker_utils import ( get_access_credentials, request_data_from_registry, @@ -175,6 +182,77 @@ def acr_helm_repo_add(cmd, p.wait() +def acr_helm_install_cli(client_version='2.16.3', install_location=None, yes=False): + """Install Helm command-line tool.""" + + if client_version >= '3': + logger.warning('Please note that "az acr helm" commands do not work with Helm 3, ' + 'but you can still push Helm chart to ACR using a different command flow. ' + 'For more information, please check out ' + 'https://docs.microsoft.com/en-us/azure/container-registry/container-registry-helm-repos') + + install_location, install_dir, cli = _process_helm_install_location_info(install_location) + + client_version = "v%s" % client_version + source_url = 'https://get.helm.sh/{}' + package, folder = _get_helm_package_name(client_version) + download_path = '' + + if not package: + raise CLIError('No prebuilt binary for current system.') + + try: + import tempfile + with tempfile.TemporaryDirectory() as tmp_dir: + download_path = os.path.join(tmp_dir, package) + _urlretrieve(source_url.format(package), download_path) + _unzip(download_path, tmp_dir) + + sub_dir = os.path.join(tmp_dir, folder) + # Ask user to check license + if not yes: + with open(os.path.join(sub_dir, 'LICENSE')) as f: + text = f.read() + logger.warning(text) + user_confirmation('Before proceeding with the installation, ' + 'please confirm that you have read and agreed the above license.') + + # Move files from temporary location to specified location + import shutil + import stat + for f in os.scandir(sub_dir): + # Rename helm to specified name + target_path = install_location if os.path.splitext(f.name)[0] == 'helm' \ + else os.path.join(install_dir, f.name) + logger.debug('Moving %s to %s', f.path, target_path) + shutil.move(f.path, target_path) + + if os.path.splitext(f.name)[0] in ('helm', 'tiller'): + os.chmod(target_path, os.stat(target_path).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + except IOError as e: + import traceback + logger.debug(traceback.format_exc()) + raise CLIError('Error while installing {} to {}: {}'.format(cli, install_dir, e)) + + logger.warning('Successfully installed %s to %s.', cli, install_dir) + # Remind user to add to path + system = platform.system() + if system == 'Windows': # be verbose, as the install_location likely not in Windows's search PATHs + env_paths = os.environ['PATH'].split(';') + found = next((x for x in env_paths if x.lower().rstrip('\\') == install_dir.lower()), None) + if not found: + # pylint: disable=logging-format-interpolation + logger.warning('Please add "{0}" to your search PATH so the `{1}` can be found. 2 options: \n' + ' 1. Run "set PATH=%PATH%;{0}" or "$env:path += \'{0}\'" for PowerShell. ' + 'This is good for the current command session.\n' + ' 2. Update system PATH environment variable by following ' + '"Control Panel->System->Advanced->Environment Variables", and re-open the command window. ' + 'You only need to do it once'.format(install_dir, cli)) + else: + logger.warning('Please ensure that %s is in your search PATH, so the `%s` command can be found.', + install_dir, cli) + + def get_helm_command(is_diagnostics_context=False): from ._errors import HELM_COMMAND_ERROR helm_command = 'helm' @@ -230,3 +308,98 @@ def _get_chart_package_name(chart, version, prov=False): return '{}.prov'.format(chart_package_name) return chart_package_name + + +def _process_helm_install_location_info(install_location): + if not install_location or install_location.isspace(): + raise CLIError('Invalid install location.') + + install_dir, cli = os.path.dirname(install_location), os.path.basename(install_location) + if not install_dir: + # Use current working directory + install_dir = os.getcwd() + install_location = os.path.join(install_dir, cli) + # Ensure installation directory exists + if not os.path.exists(install_dir): + os.makedirs(install_dir) + if not cli: + system = platform.system() + cli = 'helm.exe' if system == 'Windows' else 'helm' + install_location = os.path.join(install_dir, cli) + + return install_location, install_dir, cli + + +def _get_helm_package_name(client_version): + package_template = 'helm-{}-{}-{}.{}' + folder_template = '{}-{}' + package = '' + folder = '' + + # Reference: https://github.com/helm/helm/blob/master/scripts/get + archs = { + 'armv5': 'armv5', + 'armv6': 'armv6', + 'armv7': 'arm', + 'aarch64': 'arm64', + 'x86': '386', + 'x86_64': 'amd64', + 'i686': '386', + 'i386': '386', + 'AMD64': 'amd64', + 'ppc64le': 'ppc64le', + 's390x': 's390x' + } + machine = platform.machine() + if machine not in archs: + return None, None + arch = archs[machine] + + system = platform.system().lower() + if system == 'windows': + package = package_template.format(client_version, system, arch, 'zip') + elif system in ('linux', 'darwin'): + package = package_template.format(client_version, system, arch, 'tar.gz') + else: + return None, None + + folder = folder_template.format(system, arch) + return package, folder + + +def _ssl_context(): + import sys + import ssl + + if sys.version_info < (3, 4) or (in_cloud_console() and platform.system() == 'Windows'): + try: + return ssl.SSLContext(ssl.PROTOCOL_TLS) # added in python 2.7.13 and 3.6 + except AttributeError: + return ssl.SSLContext(ssl.PROTOCOL_TLSv1) + + return ssl.create_default_context() + + +def _urlretrieve(url, path): + logger.warning('Downloading client from %s, it may take a long time...', url) + with urlopen(url, context=_ssl_context()) as response: + logger.debug('Start downloading from %s to %s', url, path) + # Open for writing in binary mode + with open(path, "wb") as f: + f.write(response.read()) + logger.debug('Successfully downloaded from %s to %s', url, path) + + +def _unzip(src, dest): + logger.debug('Extracting %s to %s.', src, dest) + system = platform.system() + if system == 'Windows': + import zipfile + with zipfile.ZipFile(src, 'r') as zipObj: + zipObj.extractall(dest) + elif system in ('Linux', 'Darwin'): + import tarfile + with tarfile.open(src, 'r') as tarObj: + tarObj.extractall(dest) + else: + raise CLIError('The current system is not supported.')