diff --git a/src/connectedk8s/HISTORY.rst b/src/connectedk8s/HISTORY.rst index 8516198d5d2..233d5d7fb7e 100644 --- a/src/connectedk8s/HISTORY.rst +++ b/src/connectedk8s/HISTORY.rst @@ -2,6 +2,15 @@ Release History =============== +1.11.1 ++++++++ +* Added ARM64 support for Helm binary installation. +* Handle removal of '--all' flag in Helm 4 to ensure compatibility. +* Added Helm overrides support for Application Gateway for Containers (AGC). +* Updated CSP version. +* Updated CLIENT_PROXY_VERSION to 1.3.033892. +* Updated pre-diagnostics version. + 1.11.0 +++++ * [Breaking Change] Removed deprecated '--app-id' and '--app-secret' RBAC parameters from the extension. diff --git a/src/connectedk8s/azext_connectedk8s/_constants.py b/src/connectedk8s/azext_connectedk8s/_constants.py index 7c0292e601c..9fc6432694a 100644 --- a/src/connectedk8s/azext_connectedk8s/_constants.py +++ b/src/connectedk8s/azext_connectedk8s/_constants.py @@ -476,7 +476,7 @@ ) DNS_Check_Result_String = "DNS Result:" AZ_CLI_ADAL_TO_MSAL_MIGRATE_VERSION = "2.30.0" -CLIENT_PROXY_VERSION = "1.3.032281" +CLIENT_PROXY_VERSION = "1.3.033892" CLIENT_PROXY_FOLDER = ".clientproxy" API_SERVER_PORT = 47011 CLIENT_PROXY_PORT = 47010 @@ -491,7 +491,7 @@ # URL constants CLIENT_PROXY_MCR_TARGET = "azureconnectivity/proxy" HELM_MCR_URL = "azurearck8s/helm" -HELM_VERSION = "v3.12.2" +HELM_VERSION = "v3.20.1" Download_And_Install_Kubectl_Fault_Type = "Failed to download and install kubectl" Azure_Access_Token_Variable = "AZURE_ACCESS_TOKEN" Provisioned_Cluster_Kind = "provisionedcluster" diff --git a/src/connectedk8s/azext_connectedk8s/_utils.py b/src/connectedk8s/azext_connectedk8s/_utils.py index c44c1144632..732b7bf3621 100644 --- a/src/connectedk8s/azext_connectedk8s/_utils.py +++ b/src/connectedk8s/azext_connectedk8s/_utils.py @@ -104,11 +104,11 @@ def validate_connect_rp_location(cmd: CLICommand, location: str) -> None: "Failed to fetch resource provider details", ) - for resourceTypes in providerDetails.resource_types: # type: ignore[attr-defined] + for resourceTypes in providerDetails.resource_types: # type: ignore[union-attr] if resourceTypes.resource_type == "connectedClusters": rp_locations = [ location.replace(" ", "").lower() - for location in resourceTypes.locations + for location in resourceTypes.locations # type: ignore[union-attr] ] if location.lower() not in rp_locations: telemetry.set_exception( @@ -1315,7 +1315,7 @@ def helm_install_release( ] # Special configurations from 2022-09-01 ARM metadata. - # "dataplaneEndpoints" property does not appear in arm_metadata structure for public and AGC clouds. + # "dataplaneEndpoints" does not appear in arm_metadata for public and AGC if "dataplaneEndpoints" in arm_metadata: if "arcConfigEndpoint" in arm_metadata["dataplaneEndpoints"]: notification_endpoint = arm_metadata["dataplaneEndpoints"][ @@ -1484,6 +1484,25 @@ def redact_sensitive_fields_from_string(input_text: str) -> str: return input_text +def get_helm_major_version(helm_client_location: str) -> int: + """Returns the major version of the helm client (e.g. 3 or 4).""" + try: + result = Popen( + [helm_client_location, "version", "--short"], + stdout=PIPE, + stderr=PIPE, + ) + out, _ = result.communicate() + version_str = out.decode("ascii").strip() + # version_str is like "v3.17.0+gabcdef" or "v4.1.3+gabcdef" + match = re.match(r"v(\d+)\.", version_str) + if match: + return int(match.group(1)) + except (OSError, ValueError): + pass + return 3 # assume Helm 3 if we cannot determine version + + def get_release_namespace( kube_config: str | None, kube_context: str | None, @@ -1494,11 +1513,14 @@ def get_release_namespace( cmd_helm_release = [ helm_client_location, "list", - "-a", "--all-namespaces", "--output", "json", ] + # Helm 4 removed the --all flag (all releases are shown by default). + # Helm 3 requires --all to include non-deployed releases. + if get_helm_major_version(helm_client_location) < 4: + cmd_helm_release.insert(2, "--all") if kube_config: cmd_helm_release.extend(["--kubeconfig", kube_config]) if kube_context: diff --git a/src/connectedk8s/azext_connectedk8s/custom.py b/src/connectedk8s/azext_connectedk8s/custom.py index d0e399bcbb2..d1dc4b2ac87 100644 --- a/src/connectedk8s/azext_connectedk8s/custom.py +++ b/src/connectedk8s/azext_connectedk8s/custom.py @@ -1251,6 +1251,134 @@ def check_kube_connection() -> str: assert False +def _resolve_helm_pull_target( + mcr_url: str, + helm_mcr_repo: str, + helm_version: str, + operating_system: str, + arch: str, +) -> str: + """Return the ORAS pull target for the helm binary. + + Tries the arch-specific tag first (e.g. ``helm-v3.20.1-linux-arm64``). + If that tag does not exist, falls back to the manifest list tag + (``helm-v3.20.1``) and resolves the correct entry by matching the + ``org.opencontainers.image.title`` annotation on each child manifest. + + Uses the OCI Distribution v2 HTTP API directly so that the logic is + independent of the ``oras`` library version installed. + + :param mcr_url: MCR hostname (e.g. ``mcr.microsoft.com``) + :param helm_mcr_repo: repository path within MCR (e.g. ``azurearck8s/helm``) + :param helm_version: helm version string including the leading ``v`` (e.g. ``v3.20.1``) + :param operating_system: lower-case OS name: ``linux``, ``darwin``, or ``windows`` + :param arch: CPU architecture: ``amd64`` or ``arm64`` + :returns: full ORAS pull target string (tag-based or digest-based) + """ + import requests as http_client # pylint: disable=import-outside-toplevel + + arch_specific_tag = f"helm-{helm_version}-{operating_system}-{arch}" + arch_specific_target = f"{mcr_url}/{helm_mcr_repo}:{arch_specific_tag}" + base_api = f"https://{mcr_url}/v2/{helm_mcr_repo}/manifests" + + # OCI media types required by MCR (HEAD/GET return 404 without Accept). + oci_accept = ( + "application/vnd.oci.image.manifest.v1+json, " + "application/vnd.oci.image.index.v1+json" + ) + + # Check whether the arch-specific tag exists. + try: + response = http_client.head( + f"{base_api}/{arch_specific_tag}", + headers={"Accept": oci_accept}, + timeout=30, + ) + if response.status_code == 200: + return arch_specific_target + logger.debug( + "Arch-specific tag %s returned HTTP %d; trying manifest list.", + arch_specific_tag, + response.status_code, + ) + except Exception as e: # pylint: disable=broad-except + logger.debug( + "Arch-specific tag check failed (%s); trying manifest list.", + e, + ) + + # Fall back to the manifest list tag and match via annotation title. + # Annotations live on each child manifest, not on the index entries, + # so we must fetch every child manifest to find the right one. + manifest_list_tag = f"helm-{helm_version}" + expected_title_prefix = f"helm-{helm_version}-{operating_system}-{arch}" + try: + response = http_client.get( + f"{base_api}/{manifest_list_tag}", + headers={"Accept": oci_accept}, + timeout=30, + ) + if response.status_code != 200: + raise CLIInternalError( + f"Could not resolve helm binary for {operating_system}/{arch}. " + f"Arch-specific tag '{arch_specific_tag}' check failed and " + f"manifest list '{manifest_list_tag}' returned HTTP {response.status_code}." + ) + + index = response.json() + for entry in index.get("manifests", []): + # Check platform fields if present (future-proof). + plat = entry.get("platform", {}) + if plat.get("os") == operating_system and plat.get("architecture") == arch: + digest = entry["digest"] + logger.debug( + "Resolved %s/%s via platform field to digest %s.", + operating_system, + arch, + digest, + ) + return f"{mcr_url}/{helm_mcr_repo}@{digest}" + + # Annotations are on child manifests; fetch each one to match. + for entry in index.get("manifests", []): + digest = entry.get("digest", "") + try: + child_resp = http_client.get( + f"{base_api}/{digest}", + headers={"Accept": oci_accept}, + timeout=30, + ) + if child_resp.status_code != 200: + continue + child = child_resp.json() + title = child.get("annotations", {}).get( + "org.opencontainers.image.title", "" + ) + if title.startswith(expected_title_prefix): + logger.debug( + "Resolved %s/%s via child annotation title '%s' to digest %s.", + operating_system, + arch, + title, + digest, + ) + return f"{mcr_url}/{helm_mcr_repo}@{digest}" + except Exception: # pylint: disable=broad-except + continue + + raise CLIInternalError( + f"Could not resolve helm binary for {operating_system}/{arch}. " + f"No matching entry found in manifest list '{manifest_list_tag}'." + ) + except CLIInternalError: + raise + except Exception as e: # pylint: disable=broad-except + raise CLIInternalError( + f"Could not resolve helm binary for {operating_system}/{arch}. " + f"Manifest list resolution failed: {e}" + ) from e + + def install_helm_client(cmd: CLICommand) -> str: print( f"Step: {utils.get_utctimestring()}: Install Helm client if it does not exist" @@ -1263,6 +1391,7 @@ def install_helm_client(cmd: CLICommand) -> str: # Fetch system related info operating_system = platform.system().lower() machine_type = platform.machine() + arch = "arm64" if machine_type.lower() in ("aarch64", "arm64") else "amd64" # Send machine telemetry telemetry.add_extension_event( @@ -1271,20 +1400,18 @@ def install_helm_client(cmd: CLICommand) -> str: # Set helm binary download & install locations if operating_system == "windows": download_location_string = f".azure\\helm\\{consts.HELM_VERSION}" - download_file_name = f"helm-{consts.HELM_VERSION}-{operating_system}-amd64.zip" + download_file_name = f"helm-{consts.HELM_VERSION}-{operating_system}-{arch}.zip" install_location_string = ( - f".azure\\helm\\{consts.HELM_VERSION}\\{operating_system}-amd64\\helm.exe" + f".azure\\helm\\{consts.HELM_VERSION}\\{operating_system}-{arch}\\helm.exe" ) - artifactTag = f"helm-{consts.HELM_VERSION}-{operating_system}-amd64" elif operating_system == "linux" or operating_system == "darwin": download_location_string = f".azure/helm/{consts.HELM_VERSION}" download_file_name = ( - f"helm-{consts.HELM_VERSION}-{operating_system}-amd64.tar.gz" + f"helm-{consts.HELM_VERSION}-{operating_system}-{arch}.tar.gz" ) install_location_string = ( - f".azure/helm/{consts.HELM_VERSION}/{operating_system}-amd64/helm" + f".azure/helm/{consts.HELM_VERSION}/{operating_system}-{arch}/helm" ) - artifactTag = f"helm-{consts.HELM_VERSION}-{operating_system}-amd64" else: telemetry.set_exception( exception="Unsupported OS for installing helm client", @@ -1296,15 +1423,15 @@ def install_helm_client(cmd: CLICommand) -> str: ) download_location = os.path.expanduser(os.path.join("~", download_location_string)) - download_dir = os.path.dirname(download_location) install_location = os.path.expanduser(os.path.join("~", install_location_string)) # Download compressed Helm binary if not already present if not os.path.isfile(install_location): - # Creating the helm folder if it doesnt exist - if not os.path.exists(download_dir): + # The archive is downloaded to ~/.azure/helm//. + # Ensure the directory exists first to avoid file-not-found errors. + if not os.path.exists(download_location): try: - os.makedirs(download_dir) + os.makedirs(download_location) except Exception as e: telemetry.set_exception( exception=e, @@ -1318,15 +1445,23 @@ def install_helm_client(cmd: CLICommand) -> str: "Downloading helm client for first time. This can take few minutes..." ) + retry_count = 3 + retry_delay = 5 + # Helm binaries are downloaded from MCR artifacts for all architectures. mcr_url = utils.get_mcr_path(cmd.cli_ctx.cloud.endpoints.active_directory) client = oras.client.OrasClient(hostname=mcr_url) - retry_count = 3 - retry_delay = 5 + pull_target = _resolve_helm_pull_target( + mcr_url, + consts.HELM_MCR_URL, + consts.HELM_VERSION, + operating_system, + arch, + ) for i in range(retry_count): try: client.pull( - target=f"{mcr_url}/{consts.HELM_MCR_URL}:{artifactTag}", + target=pull_target, outdir=download_location, ) break diff --git a/src/connectedk8s/connectedk8s-1.11.1-py2.py3-none-any.whl b/src/connectedk8s/connectedk8s-1.11.1-py2.py3-none-any.whl new file mode 100644 index 00000000000..82a6fd78d05 Binary files /dev/null and b/src/connectedk8s/connectedk8s-1.11.1-py2.py3-none-any.whl differ diff --git a/src/connectedk8s/setup.py b/src/connectedk8s/setup.py index 85b0f20e007..76e54950857 100644 --- a/src/connectedk8s/setup.py +++ b/src/connectedk8s/setup.py @@ -13,7 +13,7 @@ # TODO: Confirm this is the right version number you want and it matches your # HISTORY.rst entry. -VERSION = "1.11.0" +VERSION = "1.11.1" # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers