diff --git a/src/connectedk8s/azext_connectedk8s/_constants.py b/src/connectedk8s/azext_connectedk8s/_constants.py index 15eb12ba05f..b8eaa6f048c 100644 --- a/src/connectedk8s/azext_connectedk8s/_constants.py +++ b/src/connectedk8s/azext_connectedk8s/_constants.py @@ -163,6 +163,7 @@ Create_Directory_Fault_Type = ( "Error while creating directory for placing the executable" ) +Remove_File_Fault_Type = "Error while deleting the specified file" Run_Clientproxy_Fault_Type = "Error while starting client proxy process." Post_Hybridconn_Fault_Type = ( "Error while posting hybrid connection details to proxy process" @@ -460,23 +461,20 @@ ) DNS_Check_Result_String = "DNS Result:" AZ_CLI_ADAL_TO_MSAL_MIGRATE_VERSION = "2.30.0" -CLIENT_PROXY_VERSION = "1.3.022011" +CLIENT_PROXY_VERSION = "1.3.029301" +CLIENT_PROXY_FOLDER = ".clientproxy" API_SERVER_PORT = 47011 CLIENT_PROXY_PORT = 47010 CLIENTPROXY_CLIENT_ID = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" API_CALL_RETRIES = 12 DEFAULT_REQUEST_TIMEOUT = 10 # seconds -RELEASE_DATE_WINDOWS = "release12-01-23" -RELEASE_DATE_LINUX = "release12-01-23" CSP_REFRESH_TIME = 300 # Default timeout in seconds for Onboarding Helm Install DEFAULT_MAX_ONBOARDING_TIMEOUT_HELMVALUE_SECONDS = "1200" # URL constants -CSP_Storage_Url = "https://k8sconnectcsp.azureedge.net" -CSP_Storage_Url_Mooncake = "https://k8sconnectcsp.blob.core.chinacloudapi.cn" -CSP_Storage_Url_Fairfax = "https://k8sconnectcsp.azureedge.us" +CLIENT_PROXY_MCR_TARGET = "mcr.microsoft.com/azureconnectivity/proxy" HELM_STORAGE_URL = "https://k8connecthelm.azureedge.net" HELM_VERSION = "v3.12.2" Download_And_Install_Kubectl_Fault_Type = "Failed to download and install kubectl" diff --git a/src/connectedk8s/azext_connectedk8s/_fileutils.py b/src/connectedk8s/azext_connectedk8s/_fileutils.py new file mode 100644 index 00000000000..a667354a4a6 --- /dev/null +++ b/src/connectedk8s/azext_connectedk8s/_fileutils.py @@ -0,0 +1,42 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import os + +from azure.cli.core import azclierror, telemetry +from knack import log + +import azext_connectedk8s._constants as consts + +logger = log.get_logger(__name__) + + +def delete_file(file_path: str, message: str, warning: bool = False) -> None: + # pylint: disable=broad-except + if os.path.isfile(file_path): + try: + os.remove(file_path) + except Exception as e: + telemetry.set_exception( + exception=e, + fault_type=consts.Remove_File_Fault_Type, + summary=f"Unable to delete file at {file_path}", + ) + if warning: + logger.warning(message) + else: + raise azclierror.FileOperationError(message + "Error: " + str(e)) from e + + +def create_directory(file_path: str, error_message: str) -> None: + try: + os.makedirs(file_path) + except Exception as e: + telemetry.set_exception( + exception=e, + fault_type=consts.Create_Directory_Fault_Type, + summary="Unable to create installation directory", + ) + raise azclierror.FileOperationError(error_message + "Error: " + str(e)) from e diff --git a/src/connectedk8s/azext_connectedk8s/_troubleshootutils.py b/src/connectedk8s/azext_connectedk8s/_troubleshootutils.py index ccbe101f772..831f36e0762 100644 --- a/src/connectedk8s/azext_connectedk8s/_troubleshootutils.py +++ b/src/connectedk8s/azext_connectedk8s/_troubleshootutils.py @@ -1769,8 +1769,7 @@ def check_msi_expiry(connected_cluster: ConnectedCluster) -> str: # To handle any exception that may occur during the execution except Exception as e: logger.exception( - "An exception has occured while performing msi expiry check on the " - "cluster." + "An exception has occured while performing msi expiry check on the cluster." ) telemetry.set_exception( exception=e, diff --git a/src/connectedk8s/azext_connectedk8s/clientproxyhelper/_binaryutils.py b/src/connectedk8s/azext_connectedk8s/clientproxyhelper/_binaryutils.py new file mode 100644 index 00000000000..56f7b218b7e --- /dev/null +++ b/src/connectedk8s/azext_connectedk8s/clientproxyhelper/_binaryutils.py @@ -0,0 +1,277 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +import os +import stat +import tarfile +import time +from glob import glob +from typing import List, Optional + +import oras.client # type: ignore[import-untyped] +from azure.cli.core import azclierror, telemetry +from azure.cli.core.style import Style, print_styled_text +from knack import log + +import azext_connectedk8s._constants as consts +import azext_connectedk8s._fileutils as file_utils + +logger = log.get_logger(__name__) + + +# Downloads client side proxy to connect to Arc Connectivity Platform +def install_client_side_proxy( + arc_proxy_folder: Optional[str], debug: bool = False +) -> str: + client_operating_system = _get_client_operating_system() + client_architecture = _get_client_architeture() + install_dir = _get_proxy_install_dir(arc_proxy_folder) + proxy_name = _get_proxy_filename(client_operating_system, client_architecture) + install_location = os.path.join(install_dir, proxy_name) + + # Only download new proxy if it doesn't exist already + try: + if not os.path.isfile(install_location): + if not os.path.isdir(install_dir): + file_utils.create_directory( + install_dir, + f"Failed to create client proxy directory '{install_dir}'.", + ) + # if directory exists, delete any older versions of the proxy + else: + older_version_location = _get_older_version_proxy_path(install_dir) + older_version_files = glob(older_version_location) + for f in older_version_files: + file_utils.delete_file( + f, f"failed to delete older version file {f}", warning=True + ) + + _download_proxy_from_MCR( + install_dir, proxy_name, client_operating_system, client_architecture + ) + _check_proxy_installation(install_dir, proxy_name, debug) + + except Exception as e: + telemetry.set_exception( + exception=e, + fault_type=consts.Create_CSPExe_Fault_Type, + summary="Unable to create proxy executable", + ) + raise e + + return install_location + + +def _download_proxy_from_MCR( + dest_dir: str, proxy_name: str, operating_system: str, architecture: str +) -> None: + mar_target = f"{consts.CLIENT_PROXY_MCR_TARGET}/{operating_system.lower()}/{architecture}/arc-proxy" + logger.debug( + "Downloading Arc Connectivity Proxy from %s in Microsoft Artifact Regristy.", + mar_target, + ) + + client = oras.client.OrasClient() + t0 = time.time() + + try: + response = client.pull( + target=f"{mar_target}:{consts.CLIENT_PROXY_VERSION}", outdir=dest_dir + ) + except Exception as e: + telemetry.set_exception( + exception=e, + fault_type=consts.Download_Exe_Fault_Type, + summary="Unable to download clientproxy executable.", + ) + raise azclierror.CLIInternalError( + f"Failed to download Arc Connectivity proxy with error {e!s}. Please try again." + ) + + time_elapsed = time.time() - t0 + + proxy_data = { + "Context.Default.AzureCLI.ArcProxyDownloadTime": time_elapsed, + "Context.Default.AzureCLI.ArcProxyVersion": consts.CLIENT_PROXY_VERSION, + } + telemetry.add_extension_event("connectedk8s", proxy_data) + + proxy_package_path = _get_proxy_package_path_from_oras_response(response) + _extract_proxy_tar_files(proxy_package_path, dest_dir, proxy_name) + file_utils.delete_file( + proxy_package_path, + f"Failed to delete {proxy_package_path}. Please delete manually.", + True, + ) + + +def _get_proxy_package_path_from_oras_response(pull_response: List[str]) -> str: + if not isinstance(pull_response, list): + raise azclierror.CLIInternalError( + "Attempt to download Arc Connectivity Proxy returned unnexpected result. Please try again." + ) + + if len(pull_response) != 1: + for r in pull_response: + file_utils.delete_file( + r, f"Failed to delete {r}. Please delete it manually.", True + ) + raise azclierror.CLIInternalError( + "Attempt to download Arc Connectivity Proxy returned unnexpected result. Please try again." + ) + + proxy_package_path = pull_response[0] + + if not os.path.isfile(proxy_package_path): + raise azclierror.CLIInternalError( + "Unable to download Arc Connectivity Proxy. Please try again." + ) + + logger.debug("Proxy package downloaded to %s", proxy_package_path) + + return proxy_package_path + + +def _extract_proxy_tar_files( + proxy_package_path: str, install_dir: str, proxy_name: str +) -> None: + with tarfile.open(proxy_package_path, "r:gz") as tar: + members = [] + for member in tar.getmembers(): + if member.isfile(): + filenames = member.name.split("/") + + if len(filenames) != 2: + tar.close() + file_utils.delete_file( + proxy_package_path, + f"Failed to delete {proxy_package_path}. Please delete it manually.", + True, + ) + raise azclierror.CLIInternalError( + "Attempt to download Arc Connectivity Proxy returned unnexpected result. Please try again." + ) + + member.name = filenames[1] + + if member.name.startswith("arcproxy"): + member.name = proxy_name + elif member.name.lower() not in ["license.txt", "thirdpartynotice.txt"]: + tar.close() + file_utils.delete_file( + proxy_package_path, + f"Failed to delete {proxy_package_path}. Please delete it manually.", + True, + ) + raise azclierror.CLIInternalError( + "Attempt to download Arc Connectivity Proxy returned unnexpected result. Please try again." + ) + + members.append(member) + + tar.extractall(members=members, path=install_dir) + + +def _check_proxy_installation( + install_dir: str, proxy_name: str, debug: bool = False +) -> None: + proxy_filepath = os.path.join(install_dir, proxy_name) + os.chmod(proxy_filepath, os.stat(proxy_filepath).st_mode | stat.S_IXUSR) + if os.path.isfile(proxy_filepath): + if debug: + print_styled_text( + ( + Style.SUCCESS, + f"Successfully installed Arc Connectivity Proxy file {proxy_filepath}", + ) + ) + else: + raise azclierror.CLIInternalError( + "Failed to install required Arc Connectivity Proxy. " + f"Couldn't find expected file {proxy_filepath}. Please try again." + ) + + license_files = ["LICENSE.txt", "ThirdPartyNotice.txt"] + for file in license_files: + file_location = os.path.join(install_dir, file) + if os.path.isfile(file_location): + if debug: + print_styled_text( + ( + Style.SUCCESS, + f"Successfully installed Arc Connectivity Proxy License file {file_location}", + ) + ) + else: + logger.warning( + "Failed to download Arc Connectivity Proxy license file %s. Couldn't find expected file %s. " + "This won't affect your connection.", + file, + file_location, + ) + + +def _get_proxy_filename(operating_system: str, architecture: str) -> str: + if operating_system.lower() == "darwin" and architecture == "386": + raise azclierror.BadRequestError("Unsupported Darwin OS with 386 architecture.") + proxy_filename = f"arcProxy_{operating_system.lower()}_{architecture}_{consts.CLIENT_PROXY_VERSION.replace('.', '_')}" + if operating_system.lower() == "windows": + proxy_filename += ".exe" + return proxy_filename + + +def _get_older_version_proxy_path( + install_dir: str, +) -> str: + proxy_name = "arcProxy*" + return os.path.join(install_dir, proxy_name) + + +def _get_proxy_install_dir(arc_proxy_folder: Optional[str]) -> str: + if not arc_proxy_folder: + return os.path.expanduser(os.path.join("~", consts.CLIENT_PROXY_FOLDER)) + return arc_proxy_folder + + +def _get_client_architeture() -> str: + import platform + + machine = platform.machine() + architecture = None + + logger.debug("Platform architecture: %s", machine) + + if "arm64" in machine.lower() or "aarch64" in machine.lower(): + architecture = "arm64" + elif machine.endswith("64"): + architecture = "amd64" + elif machine.endswith("86"): + architecture = "386" + elif machine == "": + raise azclierror.ClientRequestError( + "Couldn't identify the platform architecture." + ) + else: + raise azclierror.ClientRequestError( + f"Unsuported architecture: {machine} is not currently supported" + ) + + return architecture + + +def _get_client_operating_system() -> str: + import platform + + operating_system = platform.system() + + if operating_system.lower() not in ("linux", "darwin", "windows"): + telemetry.set_exception( + exception="Unsupported OS", + fault_type=consts.Unsupported_Fault_Type, + summary=f"{operating_system} is not supported yet", + ) + raise azclierror.ClientRequestError( + f"The {operating_system} platform is not currently supported." + ) + return operating_system diff --git a/src/connectedk8s/azext_connectedk8s/custom.py b/src/connectedk8s/azext_connectedk8s/custom.py index e195ce04261..ab1c49cc3b5 100644 --- a/src/connectedk8s/azext_connectedk8s/custom.py +++ b/src/connectedk8s/azext_connectedk8s/custom.py @@ -19,7 +19,6 @@ import urllib.request from base64 import b64decode, b64encode from concurrent.futures import ThreadPoolExecutor -from glob import glob from subprocess import DEVNULL, PIPE, Popen from typing import TYPE_CHECKING, Any, Iterable @@ -36,7 +35,6 @@ ManualInterrupt, MutuallyExclusiveArgumentError, RequiredArgumentMissingError, - UnclassifiedUserFault, ValidationError, ) from azure.cli.core.commands import LongRunningOperation @@ -57,6 +55,7 @@ import azext_connectedk8s._precheckutils as precheckutils import azext_connectedk8s._troubleshootutils as troubleshootutils import azext_connectedk8s._utils as utils +import azext_connectedk8s.clientproxyhelper._binaryutils as proxybinaryutils import azext_connectedk8s.clientproxyhelper._proxylogic as proxylogic import azext_connectedk8s.clientproxyhelper._utils as clientproxyutils from azext_connectedk8s._client_factory import ( @@ -177,8 +176,10 @@ def create_connectedk8s( else get_subscription_id(cmd.cli_ctx) ) - resource_id = f"/subscriptions/{subscription_id}/resourcegroups/{resource_group_name}/providers/Microsoft.\ + resource_id = ( + f"/subscriptions/{subscription_id}/resourcegroups/{resource_group_name}/providers/Microsoft.\ Kubernetes/connectedClusters/{cluster_name}/location/{location}" + ) telemetry.add_extension_event( "connectedk8s", {"Context.Default.AzureCLI.resourceid": resource_id} ) @@ -3473,8 +3474,8 @@ def client_side_proxy_wrapper( ) args = [] - operating_system = platform.system() - proc_name = f"arcProxy{operating_system}" + operating_system = proxybinaryutils._get_client_operating_system() + proc_name = f"arcProxy_{operating_system.lower()}" telemetry.set_debug_info("CSP Version is ", consts.CLIENT_PROXY_VERSION) telemetry.set_debug_info("OS is ", operating_system) @@ -3507,103 +3508,14 @@ def client_side_proxy_wrapper( if port_error_string != "": raise ClientRequestError(port_error_string) - # Set csp url based on cloud - CSP_Url = consts.CSP_Storage_Url - if cloud == consts.Azure_ChinaCloudName: - CSP_Url = consts.CSP_Storage_Url_Mooncake - elif cloud == consts.Azure_USGovCloudName: - CSP_Url = consts.CSP_Storage_Url_Fairfax - - # Creating installation location, request uri and older version exe location depending on OS - if operating_system == "Windows": - install_location_string = ( - f".clientproxy\\arcProxy{operating_system}{consts.CLIENT_PROXY_VERSION}.exe" - ) - requestUri = f"{CSP_Url}/{consts.RELEASE_DATE_WINDOWS}/arcProxy{operating_system}{consts.CLIENT_PROXY_VERSION}.exe" - older_version_string = f".clientproxy\\arcProxy{operating_system}*.exe" - creds_string = r".azure\accessTokens.json" - - elif operating_system == "Linux" or operating_system == "Darwin": - install_location_string = ( - f".clientproxy/arcProxy{operating_system}{consts.CLIENT_PROXY_VERSION}" - ) - requestUri = f"{CSP_Url}/{consts.RELEASE_DATE_LINUX}/arcProxy{operating_system}{consts.CLIENT_PROXY_VERSION}" - older_version_string = f".clientproxy/arcProxy{operating_system}*" - creds_string = r".azure/accessTokens.json" - - else: - telemetry.set_exception( - exception="Unsupported OS", - fault_type=consts.Unsupported_Fault_Type, - summary=f"{operating_system} is not supported yet", - ) - raise ClientRequestError( - f"The {operating_system} platform is not currently supported." - ) + debug_mode = False + if "--debug" in cmd.cli_ctx.data["safe_params"]: + debug_mode = True - install_location = os.path.expanduser(os.path.join("~", install_location_string)) + install_location = proxybinaryutils.install_client_side_proxy(None, debug_mode) args.append(install_location) install_dir = os.path.dirname(install_location) - # If version specified by install location doesnt exist, then download the executable - if not os.path.isfile(install_location): - print("Setting up environment for first time use. This can take few minutes...") - # Downloading the executable - try: - response = urllib.request.urlopen(requestUri) - except Exception as e: - telemetry.set_exception( - exception=e, - fault_type=consts.Download_Exe_Fault_Type, - summary="Unable to download clientproxy executable.", - ) - raise CLIInternalError( - f"Failed to download executable with client: {e}", - recommendation="Please check your internet connection.", - ) - - responseContent = response.read() - response.close() - - # Creating the .clientproxy folder if it doesnt exist - if not os.path.exists(install_dir): - try: - os.makedirs(install_dir) - except Exception as e: - telemetry.set_exception( - exception=e, - fault_type=consts.Create_Directory_Fault_Type, - summary="Unable to create installation directory", - ) - raise ClientRequestError( - "Failed to create installation directory." + str(e) - ) - else: - older_version_string = os.path.expanduser( - os.path.join("~", older_version_string) - ) - older_version_files = glob(older_version_string) - - # Removing older executables from the directory - for file_ in older_version_files: - try: - os.remove(file_) - except OSError: - logger.warning("failed to delete older version files") - - try: - with open(install_location, "wb") as f: - f.write(responseContent) - except Exception as e: - telemetry.set_exception( - exception=e, - fault_type=consts.Create_CSPExe_Fault_Type, - summary="Unable to create proxy executable", - ) - raise ClientRequestError("Failed to create proxy executable." + str(e)) - - os.chmod(install_location, os.stat(install_location).st_mode | stat.S_IXUSR) - # Creating config file to pass config to clientproxy config_file_location = os.path.join(install_dir, "config.yml") @@ -3620,7 +3532,6 @@ def client_side_proxy_wrapper( # initializations user_type = "sat" - creds = "" dict_file: dict[str, Any] = { "server": { "httpPort": int(client_proxy_port), @@ -3641,47 +3552,6 @@ def client_side_proxy_wrapper( else: dict_file["identity"]["clientID"] = account["user"]["name"] - if not utils.is_cli_using_msal_auth(): - # Fetching creds - creds_location = os.path.expanduser(os.path.join("~", creds_string)) - try: - with open(creds_location) as f: - creds_list = json.load(f) - except Exception as e: - telemetry.set_exception( - exception=e, - fault_type=consts.Load_Creds_Fault_Type, - summary="Unable to load accessToken.json", - ) - raise FileOperationError("Failed to load credentials." + str(e)) - - user_name = account["user"]["name"] - - if user_type == "user": - key = "userId" - key2 = "refreshToken" - else: - key = "servicePrincipalId" - key2 = "accessToken" - - for i in range(len(creds_list)): - creds_obj = creds_list[i] - - if key in creds_obj and creds_obj[key] == user_name: - creds = creds_obj[key2] - break - - if creds == "": - telemetry.set_exception( - exception="Credentials of user not found.", - fault_type=consts.Creds_NotFound_Fault_Type, - summary="Unable to find creds of user", - ) - raise UnclassifiedUserFault("Credentials of user not found.") - - if user_type != "user": - dict_file["identity"]["clientSecret"] = creds - if cloud == "DOGFOOD": dict_file["cloud"] = "AzureDogFood" elif cloud == consts.Azure_ChinaCloudName: @@ -3725,10 +3595,8 @@ def client_side_proxy_wrapper( args.append("-c") args.append(config_file_location) - debug_mode = False - if "--debug" in cmd.cli_ctx.data["safe_params"]: + if debug_mode: args.append("-d") - debug_mode = True client_side_proxy_main( cmd, diff --git a/src/connectedk8s/azext_connectedk8s/tests/latest/test_connectedk8s_scenario.py b/src/connectedk8s/azext_connectedk8s/tests/latest/test_connectedk8s_scenario.py index 0d6c2377041..aa55f2800ee 100644 --- a/src/connectedk8s/azext_connectedk8s/tests/latest/test_connectedk8s_scenario.py +++ b/src/connectedk8s/azext_connectedk8s/tests/latest/test_connectedk8s_scenario.py @@ -28,6 +28,7 @@ from knack.util import CLIError import azext_connectedk8s._constants as consts +import azext_connectedk8s.clientproxyhelper._binaryutils as proxybinaryutils TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), "..")) logger = get_logger(__name__) @@ -70,15 +71,19 @@ def install_helm_client(): # Set helm binary download & install locations if operating_system == "windows": - download_location_string = f".azure\\helm\\{consts.HELM_VERSION}\\helm-{consts.HELM_VERSION}-\ + download_location_string = ( + f".azure\\helm\\{consts.HELM_VERSION}\\helm-{consts.HELM_VERSION}-\ {operating_system}-amd64.zip" + ) install_location_string = ( f".azure\\helm\\{consts.HELM_VERSION}\\{operating_system}-amd64\\helm.exe" ) requestUri = f"{consts.HELM_STORAGE_URL}/helm/helm-{consts.HELM_VERSION}-{operating_system}-amd64.zip" elif operating_system == "linux" or operating_system == "darwin": - download_location_string = f".azure/helm/{consts.HELM_VERSION}/helm-{consts.HELM_VERSION}-\ + download_location_string = ( + f".azure/helm/{consts.HELM_VERSION}/helm-{consts.HELM_VERSION}-\ {operating_system}-amd64.tar.gz" + ) install_location_string = ( f".azure/helm/{consts.HELM_VERSION}/{operating_system}-amd64/helm" ) @@ -717,8 +722,8 @@ def test_upgrade(self, resource_group): {managed_cluster_name}-admin" ) response = requests.post( - f'https://{CONFIG["location"]}.dp.kubernetesconfiguration.azure.com/azure-\ - arc-k8sagents/GetLatestHelmPackagePath?api-version=2019-11-01-preview&releaseTrain=stable' + f"https://{CONFIG['location']}.dp.kubernetesconfiguration.azure.com/azure-\ + arc-k8sagents/GetLatestHelmPackagePath?api-version=2019-11-01-preview&releaseTrain=stable" ) jsonData = json.loads(response.text) repo_path = jsonData["repositoryPath"] @@ -1009,14 +1014,11 @@ def test_proxy(self, resource_group): operating_system = platform.system() windows_os = "Windows" proxy_process_name = None - if operating_system == windows_os: - proxy_process_name = ( - f"arcProxy{operating_system}{consts.CLIENT_PROXY_VERSION}.exe" - ) - else: - proxy_process_name = ( - f"arcProxy{operating_system}{consts.CLIENT_PROXY_VERSION}" - ) + client_operating_system = proxybinaryutils._get_client_operating_system() + client_architecture = proxybinaryutils._get_client_architeture() + proxy_process_name = proxybinaryutils._get_proxy_filename( + client_operating_system, client_architecture + ) # There cannot be more than one connectedk8s proxy running, since they would use the same port. script = [ diff --git a/src/connectedk8s/setup.py b/src/connectedk8s/setup.py index e28f498f682..6c86af143d7 100644 --- a/src/connectedk8s/setup.py +++ b/src/connectedk8s/setup.py @@ -34,6 +34,7 @@ "kubernetes==24.2.0", "pycryptodome==3.20.0", "azure-mgmt-hybridcompute==7.0.0", + "oras==0.2.25", ] with open("README.md", "r", encoding="utf-8") as f: