diff --git a/src/connectedk8s/azext_connectedk8s/_constants.py b/src/connectedk8s/azext_connectedk8s/_constants.py index e25157c1361..38758bc10d3 100644 --- a/src/connectedk8s/azext_connectedk8s/_constants.py +++ b/src/connectedk8s/azext_connectedk8s/_constants.py @@ -247,6 +247,16 @@ No_Param_Error = "No parameters were specified with update command. Please run az connectedk8s update --help to check parameters available for update" Gateway_ArmId_Is_Invalid = "The provided Gateway ArmID in --gateway-resource-id {} is invalid. Please provide a valid Gateway ArmID." EnableProxy_Conflict_Error = "Conflict detected: --disable-proxy can not be set with --https-proxy, --http-proxy, --proxy-skip-range and --proxy-cert at the same time. Please run az connectedk8s update --help for more information about the parameters" + +# --proxy-skip-range keyword that expands to the Azure Arc private-link endpoints. +Proxy_Skip_Range_Arc_Keyword = "arc" +# Arc private-link endpoint host suffixes the "arc" keyword expands to. +Arc_Private_Link_Endpoints = [ + ".his.arc.azure.{cloud_based_domain}", + ".dp.kubernetesconfiguration.azure.{cloud_based_domain}", + ".guestconfiguration.azure.{cloud_based_domain}", +] + Manual_Upgrade_Called_In_Auto_Update_Enabled = ( "Manual Upgrade was called while in auto_Update enabled mode" ) diff --git a/src/connectedk8s/azext_connectedk8s/_help.py b/src/connectedk8s/azext_connectedk8s/_help.py index ebdf83064cc..878116a5263 100644 --- a/src/connectedk8s/azext_connectedk8s/_help.py +++ b/src/connectedk8s/azext_connectedk8s/_help.py @@ -25,7 +25,7 @@ - name: Onboard a connected kubernetes cluster by specifying the kubeconfig and kubecontext. text: az connectedk8s connect -g resourceGroupName -n connectedClusterName --kube-config /path/to/kubeconfig --kube-context kubeContextName - name: Onboard a connected kubernetes cluster by specifying the https proxy, http proxy, no proxy settings. - text: az connectedk8s connect -g resourceGroupName -n connectedClusterName --proxy-https https://proxy-url --proxy-http http://proxy-url --proxy-skip-range excludedIP,excludedCIDR,exampleCIDRfollowed,10.0.0.0/24 + text: az connectedk8s connect -g resourceGroupName -n connectedClusterName --proxy-https https://proxy-url --proxy-http http://proxy-url --proxy-skip-range excludedIP,excludedCIDR,exampleCIDRfollowed,10.0.0.0/24,Arc - name: Onboard a connected kubernetes cluster by specifying the https proxy, http proxy, no proxy with cert settings. text: az connectedk8s connect -g resourceGroupName -n connectedClusterName --proxy-cert /path/to/crt --proxy-https https://proxy-url --proxy-http http://proxy-url --proxy-skip-range excludedIP,excludedCIDR,exampleCIDRfollowed,10.0.0.0/24 - name: Onboard a connected kubernetes cluster with private link feature enabled by specifying private link parameters. @@ -46,7 +46,7 @@ short-summary: Update properties of the arc onboarded kubernetes cluster. examples: - name: Update proxy values for the agents - text: az connectedk8s update -g resourceGroupName -n connectedClusterName --proxy-cert /path/to/crt --proxy-https https://proxy-url --proxy-http http://proxy-url --proxy-skip-range excludedIP,excludedCIDR,exampleCIDRfollowed,10.0.0.0/24 + text: az connectedk8s update -g resourceGroupName -n connectedClusterName --proxy-cert /path/to/crt --proxy-https https://proxy-url --proxy-http http://proxy-url --proxy-skip-range excludedIP,excludedCIDR,exampleCIDRfollowed,10.0.0.0/24,Arc - name: Disable proxy settings for agents text: az connectedk8s update -g resourceGroupName -n connectedClusterName --disable-proxy - name: Disable auto-upgrade of agents diff --git a/src/connectedk8s/azext_connectedk8s/_params.py b/src/connectedk8s/azext_connectedk8s/_params.py index 480102cf4b7..50923518ccf 100644 --- a/src/connectedk8s/azext_connectedk8s/_params.py +++ b/src/connectedk8s/azext_connectedk8s/_params.py @@ -97,7 +97,8 @@ def load_arguments(self: Connectedk8sCommandsLoader, _: CLICommand) -> None: # "no_proxy", options_list=["--proxy-skip-range"], arg_group="Proxy", - help="List of URLs/CIDRs for which proxy should not to be used.", + help="List of URLs/CIDRs for which proxy should not be used. Pass the " + "keyword 'Arc' to bypass the proxy for the linked Azure Arc private-link endpoints.", ) c.argument( "proxy_cert", @@ -260,7 +261,8 @@ def load_arguments(self: Connectedk8sCommandsLoader, _: CLICommand) -> None: # "no_proxy", options_list=["--proxy-skip-range"], arg_group="Proxy", - help="List of URLs/CIDRs for which proxy should not to be used.", + help="List of URLs/CIDRs for which proxy should not be used. Pass the " + "keyword 'Arc' to bypass the proxy for the linked Azure Arc private-link endpoints.", ) c.argument( "distribution", diff --git a/src/connectedk8s/azext_connectedk8s/custom.py b/src/connectedk8s/azext_connectedk8s/custom.py index 10060515361..2329fb0ce0b 100644 --- a/src/connectedk8s/azext_connectedk8s/custom.py +++ b/src/connectedk8s/azext_connectedk8s/custom.py @@ -230,6 +230,9 @@ def create_connectedk8s( print(f"Step: {utils.get_utctimestring()}: Escape Proxy Settings, if passed in") + # Expand --proxy-skip-range service keywords (e.g. "Arc") before escaping. + no_proxy = expand_proxy_skip_range_keywords(cmd, no_proxy) + # Escaping comma, forward slash present in https proxy urls, needed for helm params. https_proxy = escape_proxy_settings(https_proxy) @@ -1321,6 +1324,45 @@ def escape_proxy_settings(proxy_setting: str | None) -> str: return proxy_setting +def get_arc_proxy_skip_range_endpoints(cmd: CLICommand) -> list[str]: + # Arc private-link data-plane hosts to bypass the proxy. Leading-dot form matches + # every region; suffix is derived so it works across public and sovereign clouds. + cloud_based_domain = get_cloud_based_domain(cmd) + return [ + endpoint.format(cloud_based_domain=cloud_based_domain) + for endpoint in consts.Arc_Private_Link_Endpoints + ] + + +def expand_proxy_skip_range_keywords(cmd: CLICommand, no_proxy: str) -> str: + # Replace the "Arc" keyword (case-insensitive) with the Arc private-link endpoints, + # keeping all other entries in order; returns the value unchanged if no keyword. + if not no_proxy: + return no_proxy + + entries = no_proxy.split(",") + if not any( + entry.strip().lower() == consts.Proxy_Skip_Range_Arc_Keyword + for entry in entries + ): + return no_proxy + + expanded: list[str] = [] + seen: set[str] = set() + for entry in entries: + stripped = entry.strip() + if stripped.lower() == consts.Proxy_Skip_Range_Arc_Keyword: + for endpoint in get_arc_proxy_skip_range_endpoints(cmd): + if endpoint.lower() not in seen: + seen.add(endpoint.lower()) + expanded.append(endpoint) + elif stripped and stripped.lower() not in seen: + seen.add(stripped.lower()) + expanded.append(stripped) + + return ",".join(expanded) + + def check_kube_connection() -> str: print(f"Step: {utils.get_utctimestring()}: Checking Connectivity to Cluster") api_instance = kube_client.VersionApi() @@ -1627,7 +1669,7 @@ def connected_cluster_exists( return True -def get_default_config_dp_endpoint(cmd: CLICommand, location: str) -> str: +def get_cloud_based_domain(cmd: CLICommand) -> str: active_directory_array = cmd.cli_ctx.cloud.endpoints.active_directory.split(".") # default for public, mc, ff clouds cloud_based_domain = active_directory_array[2] @@ -1642,7 +1684,11 @@ def get_default_config_dp_endpoint(cmd: CLICommand, location: str) -> str: + "." + active_directory_array[4] ) + return cloud_based_domain + +def get_default_config_dp_endpoint(cmd: CLICommand, location: str) -> str: + cloud_based_domain = get_cloud_based_domain(cmd) config_dp_endpoint = ( f"https://{location}.dp.kubernetesconfiguration.azure.{cloud_based_domain}" ) @@ -2439,6 +2485,9 @@ def update_connected_cluster( # Setting kubeconfig kube_config = set_kube_config(kube_config) + # Expand --proxy-skip-range service keywords (e.g. "Arc") before escaping. + no_proxy = expand_proxy_skip_range_keywords(cmd, no_proxy) + # Escaping comma, forward slash present in https proxy urls, needed for helm params. https_proxy = escape_proxy_settings(https_proxy) diff --git a/src/connectedk8s/azext_connectedk8s/tests/unittests/test_custom.py b/src/connectedk8s/azext_connectedk8s/tests/unittests/test_custom.py index 1693db1fefc..050afa2a851 100644 --- a/src/connectedk8s/azext_connectedk8s/tests/unittests/test_custom.py +++ b/src/connectedk8s/azext_connectedk8s/tests/unittests/test_custom.py @@ -5,12 +5,17 @@ import os import sys from typing import Dict, Optional +from unittest.mock import MagicMock import pytest from kubernetes.client.models import V1Node, V1NodeList, V1NodeSpec, V1ObjectMeta sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))) -from azext_connectedk8s.custom import get_kubernetes_distro, get_kubernetes_infra +from azext_connectedk8s.custom import ( + expand_proxy_skip_range_keywords, + get_kubernetes_distro, + get_kubernetes_infra, +) def create_node( @@ -86,3 +91,91 @@ def test_distro_invalid_metadata(): node = create_node(provider_id="aws://node1", labels=None, annotations=None) api_response = V1NodeList(items=[node]) assert get_kubernetes_distro(api_response) == "generic" + + +# --------------------- Tests for expand_proxy_skip_range_keywords --------------------- +def _proxy_cmd(active_directory="https://login.microsoftonline.com"): + cmd = MagicMock() + cmd.cli_ctx.cloud.endpoints.active_directory = active_directory + return cmd + + +ARC_PUBLIC = ( + ".his.arc.azure.com," + ".dp.kubernetesconfiguration.azure.com," + ".guestconfiguration.azure.com" +) + + +def test_expand_arc_keyword_public_cloud(): + assert expand_proxy_skip_range_keywords(_proxy_cmd(), "Arc") == ARC_PUBLIC + + +@pytest.mark.parametrize("keyword", ["Arc", "arc", "ARC", " aRc "]) +def test_expand_arc_keyword_is_case_and_space_insensitive(keyword): + assert expand_proxy_skip_range_keywords(_proxy_cmd(), keyword) == ARC_PUBLIC + + +def test_expand_arc_keyword_preserves_other_entries(): + out = expand_proxy_skip_range_keywords(_proxy_cmd(), "Arc,10.0.0.0/16,.svc") + assert out == ARC_PUBLIC + ",10.0.0.0/16,.svc" + + +def test_expand_arc_keyword_china_cloud(): + cmd = _proxy_cmd("https://login.chinacloudapi.cn") + out = expand_proxy_skip_range_keywords(cmd, "Arc") + assert out == ( + ".his.arc.azure.cn," + ".dp.kubernetesconfiguration.azure.cn," + ".guestconfiguration.azure.cn" + ) + + +def test_expand_arc_keyword_usgov_cloud(): + cmd = _proxy_cmd("https://login.microsoftonline.us") + out = expand_proxy_skip_range_keywords(cmd, "Arc") + assert out == ( + ".his.arc.azure.us," + ".dp.kubernetesconfiguration.azure.us," + ".guestconfiguration.azure.us" + ) + + +def test_expand_arc_keyword_ussec_cloud(): + cmd = _proxy_cmd("https://login.microsoftonline.microsoft.scloud") + out = expand_proxy_skip_range_keywords(cmd, "Arc") + assert out == ( + ".his.arc.azure.microsoft.scloud," + ".dp.kubernetesconfiguration.azure.microsoft.scloud," + ".guestconfiguration.azure.microsoft.scloud" + ) + + +def test_expand_arc_keyword_usnat_cloud(): + cmd = _proxy_cmd("https://login.microsoftonline.eaglex.ic.gov") + out = expand_proxy_skip_range_keywords(cmd, "Arc") + assert out == ( + ".his.arc.azure.eaglex.ic.gov," + ".dp.kubernetesconfiguration.azure.eaglex.ic.gov," + ".guestconfiguration.azure.eaglex.ic.gov" + ) + + +def test_expand_no_keyword_returns_unchanged(): + val = "10.0.0.0/16,.svc,localhost" + assert expand_proxy_skip_range_keywords(_proxy_cmd(), val) == val + + +def test_expand_empty_returns_unchanged(): + assert expand_proxy_skip_range_keywords(_proxy_cmd(), "") == "" + + +def test_expand_arc_keyword_deduplicates(): + out = expand_proxy_skip_range_keywords(_proxy_cmd(), "Arc,Arc") + assert out == ARC_PUBLIC + + +def test_expand_arc_keyword_dedups_case_insensitive_endpoint(): + # A user endpoint differing only in case is not duplicated in NO_PROXY. + out = expand_proxy_skip_range_keywords(_proxy_cmd(), "Arc, .his.ARC.azure.com") + assert out == ARC_PUBLIC diff --git a/testing/test/configurations/Proxy.Tests.ps1 b/testing/test/configurations/Proxy.Tests.ps1 index bda7b06e4bc..0d268506e52 100644 --- a/testing/test/configurations/Proxy.Tests.ps1 +++ b/testing/test/configurations/Proxy.Tests.ps1 @@ -28,6 +28,36 @@ Describe 'Proxy Scenario' { $n | Should -BeLessOrEqual $MAX_RETRY_ATTEMPTS } + It 'Expands the Arc keyword in --proxy-skip-range to the Azure Arc private-link endpoints' { + az connectedk8s update -n $ENVCONFIG.arcClusterName -g $ENVCONFIG.resourceGroup --proxy-skip-range Arc + $? | Should -BeTrue + Start-Sleep -Seconds 10 + + # Loop and retry until the Arc keyword expands into the no-proxy list + $n = 0 + do + { + $output = az connectedk8s show -n $ENVCONFIG.arcClusterName -g $ENVCONFIG.resourceGroup + $provisioningState = ($output | ConvertFrom-Json).provisioningState + Write-Host "Provisioning State: $provisioningState" + if ($provisioningState -eq $SUCCEEDED) { + $noProxy = helm get values -n azure-arc-release azure-arc -o yaml | grep noProxy + Write-Host "$noProxy" + if ($noProxy -match "\.his\.arc\.azure") { + break + } + } + Start-Sleep -Seconds 10 + $n += 1 + } while ($n -le $MAX_RETRY_ATTEMPTS) + $n | Should -BeLessOrEqual $MAX_RETRY_ATTEMPTS + + $noProxy = helm get values -n azure-arc-release azure-arc -o yaml | grep noProxy + $noProxy | Should -Match "\.his\.arc\.azure" + $noProxy | Should -Match "\.dp\.kubernetesconfiguration\.azure" + $noProxy | Should -Match "\.guestconfiguration\.azure" + } + It 'Disable proxy' { az connectedk8s update -n $ENVCONFIG.arcClusterName -g $ENVCONFIG.resourceGroup --disable-proxy $? | Should -BeTrue