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/__init__.py b/src/connectedk8s/azext_connectedk8s/__init__.py index 4ea68896e31..deb2cd57a70 100644 --- a/src/connectedk8s/azext_connectedk8s/__init__.py +++ b/src/connectedk8s/azext_connectedk8s/__init__.py @@ -10,6 +10,37 @@ from azext_connectedk8s._help import helps + +def _patch_urllib3_getheaders_compat() -> None: + """ + Restore ``urllib3.response.HTTPResponse.getheaders`` if the urllib3 that + az core loads is v2.x and dropped the method. The kubernetes python client + (through at least 29.x) still calls ``http_resp.getheaders()`` inside + ``ApiException.__init__``, so without this shim any non-2xx Kubernetes API + response (e.g. a 404 on ``read_namespace('azure-arc')`` during onboarding) + crashes with ``AttributeError: 'HTTPResponse' object has no attribute + 'getheaders'``. + + We can't fix this by pinning urllib3 in setup.py: az core's site-packages + appears earlier on sys.path than the extension's bundled copy, so az's + urllib3 always wins. The patch is idempotent and a no-op when getheaders + already exists. + """ + try: + from urllib3.response import HTTPResponse + except Exception: # pylint: disable=broad-except + return + if hasattr(HTTPResponse, "getheaders"): + return + + def getheaders(self): # type: ignore[no-untyped-def] + return self.headers + + HTTPResponse.getheaders = getheaders # type: ignore[attr-defined,method-assign] + + +_patch_urllib3_getheaders_compat() + if TYPE_CHECKING: from azure.cli.core import AzCli from knack.commands import CLICommand diff --git a/src/connectedk8s/azext_connectedk8s/_constants.py b/src/connectedk8s/azext_connectedk8s/_constants.py index 7c0292e601c..39659d6547e 100644 --- a/src/connectedk8s/azext_connectedk8s/_constants.py +++ b/src/connectedk8s/azext_connectedk8s/_constants.py @@ -98,6 +98,15 @@ ) Custom_Token_Env_Var_Sub_Id_Missing_Fault_Type = "Required environment variable 'AZURE_SUBSCRIPTION_ID' is not set, when using Custom Acces Token." Release_Install_Namespace = "azure-arc-release" +Helm_Release_Name = "azure-arc" +Onboarding_PrivateKey_Secret_Name = "azure-arc-connect-privatekey" +Onboarding_PrivateKey_Secret_Data_Key = "privateKey" +Min_Agent_Version_For_Secret_Injection = "1.35.0" +Min_Agent_Version_For_Secret_Injection_Preview = "1.35.0-preview" +Stable_Release_Train = "stable" +Preview_Release_Train = "preview" +Inject_PrivateKey_Secret_Fault_Type = "inject-private-key-secret-error" +Strip_Chart_PrivateKey_Secret_Fault_Type = "strip-chart-private-key-secret-error" Workload_Identity_Release_Name = "wiextension" Workload_Identity_Release_Namespace = "arc-workload-identity" Helm_Environment_File_Fault_Type = "helm-environment-file-error" @@ -476,7 +485,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 +500,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..90dabe5e4d7 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( @@ -1266,6 +1266,196 @@ def cleanup_release_install_namespace_if_exists() -> None: ) +def should_use_secret_injection_flow( + release_train: str | None, agent_version: str | None +) -> bool: + """ + Determine whether to use the secure onboarding flow that pre-creates the + onboarding private key as a Kubernetes Secret (instead of passing it through + helm values). + + Older agents whose helm chart unconditionally renders the privatekey secret + from ``global.onboardingPrivateKey`` must keep using the legacy flow, + otherwise the helm release would re-render the secret with an empty + ``privateKey`` and leave the cluster stuck in a disconnected state. The + chart change that gates the privatekey secret on the helm value being set + ships in: + + * ``stable`` release train: agent version ``1.35.0`` and above. + * ``preview`` release train: agent version ``1.35.0-preview`` and above. + * any other release train (e.g. ``dev``): always use the secure flow. + * any agent version ending in ``-dev`` (e.g. ``0.2.5738-dev``) is treated + as a dev build and always uses the secure flow, regardless of the train + DP attributed it to. This handles the case where a developer overrides + ``HELMREGISTRY`` to a dev chart while DP still reports the original + ``stable``/``preview`` train. + + From those versions onward the chart only renders the privatekey secret + when ``global.onboardingPrivateKey`` is provided, so simply omitting the + helm value is sufficient to hand secret ownership to the kubectl-injected + resource. + """ + # Dev-suffixed agent versions always use the secure flow regardless of + if agent_version and agent_version.lower().endswith("-dev"): + return True + + effective_train = (release_train or consts.Stable_Release_Train).lower() + if effective_train == consts.Stable_Release_Train: + cutoff = consts.Min_Agent_Version_For_Secret_Injection + elif effective_train == consts.Preview_Release_Train: + cutoff = consts.Min_Agent_Version_For_Secret_Injection_Preview + else: + # If not dev, or stable/preview train use the legacy flow for safety + return False + + if not agent_version: + # Cannot determine version on a gated train. Be safe and use the legacy + # flow so that older agents aren't broken. + return False + try: + return version.parse(agent_version) >= version.parse(cutoff) + except Exception: # pylint: disable=broad-except + # If we can't parse the version, fall back to the legacy flow. + return False + + +def ensure_arc_namespace_with_helm_metadata() -> None: + """ + Ensure the ``azure-arc`` namespace exists and is annotated/labeled so that + the subsequent ``helm install`` can adopt it without erroring out with + "exists and cannot be imported into the current release". + """ + api_instance = kube_client.CoreV1Api() + helm_labels = {"app.kubernetes.io/managed-by": "Helm"} + helm_annotations = { + "meta.helm.sh/release-name": consts.Helm_Release_Name, + "meta.helm.sh/release-namespace": consts.Release_Install_Namespace, + } + + try: + existing_ns = api_instance.read_namespace(consts.Arc_Namespace) + except ApiException as ex: + if ex.status != 404: + kubernetes_exception_handler( + ex, + consts.Get_Kubernetes_Namespace_Fault_Type, + error_message=f"Unable to fetch namespace '{consts.Arc_Namespace}'", + summary=f"Unable to fetch namespace '{consts.Arc_Namespace}'", + ) + return + # Namespace does not exist, create it with the required metadata. + ns_body = kube_client.V1Namespace( + metadata=kube_client.V1ObjectMeta( + name=consts.Arc_Namespace, + labels=helm_labels, + annotations=helm_annotations, + ) + ) + try: + api_instance.create_namespace(ns_body) + except ApiException as create_ex: + kubernetes_exception_handler( + create_ex, + consts.Inject_PrivateKey_Secret_Fault_Type, + error_message=f"Unable to create namespace '{consts.Arc_Namespace}'", + summary=f"Unable to create namespace '{consts.Arc_Namespace}'", + ) + return + + # Namespace exists; merge in the Helm adoption metadata so helm can manage it. + metadata = existing_ns.metadata or kube_client.V1ObjectMeta() + labels = dict(metadata.labels or {}) + annotations = dict(metadata.annotations or {}) + labels.update(helm_labels) + annotations.update(helm_annotations) + patch_body = {"metadata": {"labels": labels, "annotations": annotations}} + try: + api_instance.patch_namespace(consts.Arc_Namespace, patch_body) + except ApiException as patch_ex: + kubernetes_exception_handler( + patch_ex, + consts.Inject_PrivateKey_Secret_Fault_Type, + error_message=( + f"Unable to patch namespace '{consts.Arc_Namespace}' with Helm " + "ownership metadata" + ), + summary=( + f"Unable to patch namespace '{consts.Arc_Namespace}' with Helm " + "ownership metadata" + ), + ) + + +def inject_onboarding_private_key_secret(private_key_pem: str) -> None: + """ + Pre-create the onboarding private key as a Kubernetes Secret so the agents + can consume it without ever exposing it through helm values. The namespace + and secret are annotated/labeled for Helm adoption so the chart can manage + them on subsequent upgrades. + + This MUST be called before ``helm install`` so that the cluster never sits + in a state where the private key is missing (which would leave it stuck in + a disconnected state since the Cluster Identity Operator depends on this + secret to fetch identity certificates from HIS). + """ + print( + f"Step: {get_utctimestring()}: Pre-creating onboarding private key " + f"secret '{consts.Onboarding_PrivateKey_Secret_Name}' in namespace " + f"'{consts.Arc_Namespace}'." + ) + ensure_arc_namespace_with_helm_metadata() + + api_instance = kube_client.CoreV1Api() + secret_body = kube_client.V1Secret( + metadata=kube_client.V1ObjectMeta( + name=consts.Onboarding_PrivateKey_Secret_Name, + namespace=consts.Arc_Namespace, + labels={"app.kubernetes.io/managed-by": "Helm"}, + annotations={ + "meta.helm.sh/release-name": consts.Helm_Release_Name, + "meta.helm.sh/release-namespace": consts.Release_Install_Namespace, + }, + ), + type="Opaque", + string_data={consts.Onboarding_PrivateKey_Secret_Data_Key: private_key_pem}, + ) + + try: + api_instance.create_namespaced_secret(consts.Arc_Namespace, secret_body) + except ApiException as ex: + if ex.status != 409: + kubernetes_exception_handler( + ex, + consts.Inject_PrivateKey_Secret_Fault_Type, + error_message=( + "Unable to create onboarding private key secret " + f"'{consts.Onboarding_PrivateKey_Secret_Name}' in namespace " + f"'{consts.Arc_Namespace}'" + ), + summary="Unable to create onboarding private key secret", + ) + return + # Secret already exists - replace its contents + # so the cluster always uses a private key matching the public key in ARM + try: + api_instance.replace_namespaced_secret( + consts.Onboarding_PrivateKey_Secret_Name, + consts.Arc_Namespace, + secret_body, + ) + except ApiException as replace_ex: + kubernetes_exception_handler( + replace_ex, + consts.Inject_PrivateKey_Secret_Fault_Type, + error_message=( + "Unable to update existing onboarding private key secret " + f"'{consts.Onboarding_PrivateKey_Secret_Name}' in namespace " + f"'{consts.Arc_Namespace}'" + ), + summary="Unable to update onboarding private key secret", + ) + + # DO NOT use this method for re-put scenarios. This method involves new NS creation for helm release. For re-put scenarios, brownfield scenario needs to be handled where helm release still stays in default NS def helm_install_release( resource_manager: str, @@ -1288,6 +1478,7 @@ def helm_install_release( registry_path: str, aad_identity_principal_id: str | None, onboarding_timeout: str = consts.DEFAULT_MAX_ONBOARDING_TIMEOUT_HELMVALUE_SECONDS, + inject_private_key_via_helm: bool = True, ) -> None: cmd_helm_install = [ helm_client_location, @@ -1299,23 +1490,32 @@ def helm_install_release( f"global.kubernetesDistro={kubernetes_distro}", "--set", f"global.kubernetesInfra={kubernetes_infra}", - "--set", - f"global.onboardingPrivateKey={private_key_pem}", - "--set", - "systemDefaultValues.spnOnboarding=false", - "--set", - f"global.azureEnvironment={cloud_name}", - "--set", - "systemDefaultValues.clusterconnect-agent.enabled=true", - "--namespace", - f"{consts.Release_Install_Namespace}", - "--create-namespace", - "--output", - "json", ] + # Pass the onboarding private key through helm values only for older + # (stable < 1.35.0) agents. Newer agents have already received the key via + # a pre-created Kubernetes Secret so it never appears in helm values. + if inject_private_key_via_helm: + cmd_helm_install.extend( + ["--set", f"global.onboardingPrivateKey={private_key_pem}"] + ) + cmd_helm_install.extend( + [ + "--set", + "systemDefaultValues.spnOnboarding=false", + "--set", + f"global.azureEnvironment={cloud_name}", + "--set", + "systemDefaultValues.clusterconnect-agent.enabled=true", + "--namespace", + f"{consts.Release_Install_Namespace}", + "--create-namespace", + "--output", + "json", + ] + ) # 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 +1684,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 +1713,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..fe0f15bc557 100644 --- a/src/connectedk8s/azext_connectedk8s/custom.py +++ b/src/connectedk8s/azext_connectedk8s/custom.py @@ -304,6 +304,7 @@ def create_connectedk8s( try: kubectl_client_location = install_kubectl_client() helm_client_location = install_helm_client(cmd) + logger.debug("Using helm binary: %s", helm_client_location) except Exception as e: raise CLIInternalError( f"An exception has occured while trying to perform kubectl or helm install: {e}" @@ -1018,6 +1019,51 @@ def create_connectedk8s( print( f"Step: {utils.get_utctimestring()}: Starting to install Azure arc agents on the Kubernetes cluster." ) + + # Diagnostic: print the kubernetes/urllib3 module versions and paths that + # this extension actually loaded. Used to root-cause an environment-skew + # bug between local dev and e2e runners where `kubernetes==24.2.0` calls + # `urllib3.HTTPResponse.getheaders()` (removed in urllib3 v2) when + # constructing ApiException on non-2xx responses. Remove once resolved. + utils.log_python_dependency_diagnostics("create_connectedk8s") + + # Decide which onboarding flow to use. Stable agents below 1.35.0 still need + # the legacy flow (private key in helm values), because their helm chart + # always renders the privatekey secret from helm values and would zero it + # out on install otherwise. Newer agents (and any non-stable build) get the + # secure flow: we pre-create the namespace + secret directly via the + # Kubernetes API so the private key never appears in helm values. + use_secret_injection_flow = utils.should_use_secret_injection_flow( + release_train, azure_arc_agent_version + ) + telemetry.add_extension_event( + "connectedk8s", + { + "Context.Default.AzureCLI.OnboardingFlow": ( + "secret-injection" + if use_secret_injection_flow + else "helm-values-legacy" + ) + }, + ) + + if use_secret_injection_flow: + # Inject the private key BEFORE running helm so that the cluster always + # has the onboarding secret available - even if the subsequent helm + # install/CLI is interrupted - preventing a stuck-disconnected state. + try: + utils.inject_onboarding_private_key_secret(private_key_pem) + except Exception as e: + telemetry.set_exception( + exception=e, + fault_type=consts.Inject_PrivateKey_Secret_Fault_Type, + summary="Failed to pre-create onboarding private key secret", + ) + raise CLIInternalError( + "Failed to pre-create onboarding private key secret on the " + f"Kubernetes cluster: {e}" + ) + # Install azure-arc agents utils.helm_install_release( cmd.cli_ctx.cloud.endpoints.resource_manager, @@ -1040,6 +1086,7 @@ def create_connectedk8s( registry_path, aad_identity_principal_id, onboarding_timeout, + inject_private_key_via_helm=not use_secret_injection_flow, ) # Long Running Operation for Agent State @@ -1251,6 +1298,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 +1438,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 +1447,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 +1470,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 +1492,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/azext_connectedk8s/tests/unittests/test_utils_.py b/src/connectedk8s/azext_connectedk8s/tests/unittests/test_utils_.py index 32d1da1e3b4..3d1594705b2 100644 --- a/src/connectedk8s/azext_connectedk8s/tests/unittests/test_utils_.py +++ b/src/connectedk8s/azext_connectedk8s/tests/unittests/test_utils_.py @@ -14,6 +14,7 @@ redact_sensitive_fields_from_string, remove_rsa_private_key, scrub_proxy_url, + should_use_secret_injection_flow, ) @@ -99,5 +100,43 @@ def test_get_mcr_path(): assert get_mcr_path(input_active_directory) == expected_output +@pytest.mark.parametrize( + "release_train,agent_version,expected", + [ + # Stable train, agents older than 1.35.0 must use the legacy flow + # (helm value injection) to avoid zeroing out the secret. + ("stable", "1.34.9", False), + ("stable", "1.20.0", False), + ("STABLE", "1.14.0", False), + # Stable train at or above the cutoff uses the secure flow. + ("stable", "1.35.0", True), + ("stable", "1.36.2", True), + ("stable", "2.0.0", True), + # Preview train uses 1.35.0-preview as the cutoff (same scheme). + ("preview", "1.34.0", False), + ("preview", "1.35.0-preview", True), + ("preview", "1.36.0-preview", True), + ("PREVIEW", "1.20.0", False), + # Dev-suffixed agent versions always use the secure flow, regardless of + ("preview", "0.2.5738-dev", True), + ("stable", "0.2.6689-dev", True), + ("STABLE", "1.34.0-DEV", True), + (None, "0.2.5738-dev", True), + # Missing version on a gated train -> safe default (legacy flow). + ("stable", None, False), + ("preview", "", False), + # Missing release train defaults to "stable". + (None, "1.34.0", False), + (None, "1.35.0", True), + # Unparseable version on a gated train -> safe default (legacy flow). + ("stable", "not-a-version", False), + ], +) +def test_should_use_secret_injection_flow(release_train, agent_version, expected): + assert ( + should_use_secret_injection_flow(release_train, agent_version) is expected + ) + + if __name__ == "__main__": pytest.main() diff --git a/src/connectedk8s/connectedk8s-1.10.12-py2.py3-none-any.whl b/src/connectedk8s/connectedk8s-1.10.12-py2.py3-none-any.whl new file mode 100644 index 00000000000..d3e2a655bf0 Binary files /dev/null and b/src/connectedk8s/connectedk8s-1.10.12-py2.py3-none-any.whl differ 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..c7978246f01 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