diff --git a/src/aks-preview/HISTORY.rst b/src/aks-preview/HISTORY.rst index 124359ee053..28131578382 100644 --- a/src/aks-preview/HISTORY.rst +++ b/src/aks-preview/HISTORY.rst @@ -12,6 +12,10 @@ To release a new version, please select a new version number (usually plus 1 to Pending +++++++ +18.0.0b21 ++++++++ +* Add command `az aks bastion` to enable connections to managed Kubernetes clusters via Azure Bastion. + 18.0.0b20 +++++++ * Fix the bug affecting VMAS to VMS migration in the `az aks update` command using the `--migrate-vmas-to-vms` option. diff --git a/src/aks-preview/azext_aks_preview/_help.py b/src/aks-preview/azext_aks_preview/_help.py index 3f536b2e71a..834bdba30f9 100644 --- a/src/aks-preview/azext_aks_preview/_help.py +++ b/src/aks-preview/azext_aks_preview/_help.py @@ -3864,3 +3864,24 @@ - name: Show details of a load balancer configuration in table format text: az aks loadbalancer show -g MyResourceGroup -n kubernetes --cluster-name MyManagedCluster -o table """ + +helps['aks bastion'] = """ + type: command + short-summary: Connect to a managed Kubernetes cluster using Azure Bastion. + long-summary: The command will launch a subshell with the kubeconfig set to connect to the cluster via Bastion. Use exit or Ctrl-D (i.e. EOF) to exit the subshell. + parameters: + - name: --bastion + type: string + short-summary: The name or resource ID of a pre-deployed Bastion resource configured to connect to the current AKS cluster. + long-summary: If not specified, the command will try to identify an existing Bastion resource within the cluster's node resource group. + - name: --port + type: int + short-summary: The local port number used for the bastion connection. + long-summary: If not provided, a random port will be used. + - name: --admin + type: bool + short-summary: Use the cluster admin credentials to connect to the bastion. + examples: + - name: Connect to a managed Kubernetes cluster using Azure Bastion with custom port and admin credentials. + text: az aks bastion -g MyResourceGroup --name MyManagedCluster --bastion MyBastionResource --port 50001 --admin +""" diff --git a/src/aks-preview/azext_aks_preview/_params.py b/src/aks-preview/azext_aks_preview/_params.py index 771bbb76c6e..fc8a669d78c 100644 --- a/src/aks-preview/azext_aks_preview/_params.py +++ b/src/aks-preview/azext_aks_preview/_params.py @@ -2761,6 +2761,17 @@ def load_arguments(self, _): help="Name of the load balancer configuration. Required.", ) + with self.argument_context("aks bastion") as c: + c.argument("bastion") + c.argument("port", type=int) + c.argument("admin", action="store_true") + c.argument( + "yes", + options_list=["--yes", "-y"], + help="Do not prompt for confirmation.", + action="store_true", + ) + def _get_default_install_location(exe_name): system = platform.system() diff --git a/src/aks-preview/azext_aks_preview/bastion/__init__.py b/src/aks-preview/azext_aks_preview/bastion/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/aks-preview/azext_aks_preview/bastion/bastion.py b/src/aks-preview/azext_aks_preview/bastion/bastion.py new file mode 100644 index 00000000000..f47fae6e7ae --- /dev/null +++ b/src/aks-preview/azext_aks_preview/bastion/bastion.py @@ -0,0 +1,411 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import asyncio +import os +import socket +import subprocess +import sys +from typing import List, TextIO +from urllib.parse import urlparse + +import psutil +import yaml +from azure.cli.command_modules.acs._consts import DecoratorEarlyExitException +from azure.cli.core.azclierror import (CLIInternalError, + InvalidArgumentValueError, + ResourceNotFoundError) +from azure.cli.core.util import run_az_cmd +from azure.mgmt.core.tools import (is_valid_resource_id, + is_valid_resource_name, parse_resource_id) +from knack.log import get_logger +from knack.prompting import prompt_y_n + +logger = get_logger(__name__) + + +# pylint: disable=too-few-public-methods +class BastionResource: + def __init__(self, name, resource_group): + self.name = name + self.resource_group = resource_group + + +def aks_bastion_parse_bastion_resource( + bastion: str, resource_groups: List[str] +) -> BastionResource: + """Get the bastion resource name from the provided name or node resource group.""" + + # validate provided bastion + if bastion: + if is_valid_resource_id(bastion): + parsed_id = parse_resource_id(bastion) + return BastionResource( + name=parsed_id["name"], resource_group=parsed_id["resource_group"] + ) + if is_valid_resource_name(bastion): + for resource_group in resource_groups: + logger.debug( + "Checking bastion '%s' in resource group '%s'.", + bastion, + resource_group, + ) + # check if the bastion exists in the provided resource group + result = run_az_cmd( + [ + "network", + "bastion", + "show", + "--resource-group", + resource_group, + "--name", + bastion, + "--output", + "json", + ], + out_file=TextIO(), + ) + if result.exit_code != 0: + logger.debug( + "Failed to find bastion '%s' in resource group '%s'. Error: %s", + bastion, + resource_group, + result.error, + ) + continue + logger.debug( + "Found bastion resource: %s in resource group: %s", + bastion, + resource_group, + ) + return BastionResource(name=bastion, resource_group=resource_group) + logger.warning( + "No valid bastion resource provided: '%s'. Attempting to locate one from resource groups: '%s'.", + bastion, + resource_groups, + ) + + # list bastions in the provided resource groups + for resource_group in resource_groups: + logger.debug("Searching for bastion in resource group '%s'.", resource_group) + result = run_az_cmd( + [ + "network", + "bastion", + "list", + "--resource-group", + resource_group, + "--output", + "json", + ], + out_file=TextIO(), + ) + if result.exit_code != 0: + logger.debug( + "Failed to list bastions in resource group '%s'. Error: %s", + resource_group, + result.error, + ) + continue + bastions = result.result + if len(bastions) > 1: + logger.warning( + "Multiple bastions found in the node resource group. Using the first one." + ) + logger.debug( + "Using bastion resource: %s in resource group: %s", + bastions[0]["name"], + resource_group, + ) + return BastionResource(name=bastions[0]["name"], resource_group=resource_group) + raise ResourceNotFoundError( + "No bastion found in the provided resource groups: " + f"{', '.join(resource_groups)}. Please provide a valid bastion name or resource ID." + ) + + +def aks_bastion_get_local_port(port): + """Get an available local port for the bastion tunnel.""" + + # validate provided port + if port: + if not (1 <= port <= 65535): + raise InvalidArgumentValueError( + f"Invalid port number: {port}. Port must be between 1 and 65535." + ) + # check if the port is already in use + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + s.bind(("", port)) + except OSError as e: + raise InvalidArgumentValueError(f"Port {port} is already in use: {e}") + return port + + # find an available port + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("", 0)) # bind to an available port + port = s.getsockname()[1] + logger.debug("Using local port: %s", port) + return port + + +def aks_bastion_extension(yes): + """Check if the Azure Bastion extension is installed, and prompt to install it if not.""" + + logger.debug("Checking if the bastion extension is installed...") + result = run_az_cmd(["extension", "list", "--output", "json"], out_file=TextIO()) + if result.exit_code != 0: + raise CLIInternalError( + f"Failed to list installed extensions: {result.error}" + ) + for ext in result.result: + if ext["name"] == "bastion": + logger.debug("Bastion extension is already installed.") + return True + + msg = "The Azure Bastion extension is not installed. Do you want to install it? [y/n]: " + if not yes and not prompt_y_n(msg, default="n"): + raise DecoratorEarlyExitException( + "Bastion extension is required for this operation. " + "Please install it using 'az extension add --name bastion'." + ) + logger.debug("Installing bastion extension...") + result = run_az_cmd(["extension", "add", "--name", "bastion"], out_file=TextIO()) + if result.exit_code != 0: + raise CLIInternalError(f"Failed to install bastion extension: {result.error}") + + +def aks_bastion_set_kubeconfig(kubeconfig_path, port): + """Update the kubeconfig file to point to the local port.""" + + logger.debug("Updating kubeconfig file: %s to use port: %s", kubeconfig_path, port) + with open(kubeconfig_path, "r") as f: + data = yaml.load(f, Loader=yaml.SafeLoader) + current_context = data["current-context"] + for cluster in data["clusters"]: + if cluster["name"] == current_context: + server = cluster["cluster"]["server"] + hostname = urlparse(server).hostname + # update the server URL to point to the local port + cluster["cluster"]["server"] = f"https://localhost:{port}/" + # set the tls-server-name to the hostname + cluster["cluster"]["tls-server-name"] = hostname + break + with open(kubeconfig_path, "w") as f: + yaml.dump(data, f) + + +async def aks_bastion_runner( + bastion_resource, port, mc_id, kubeconfig_path, test_hook=None +): + """Run the bastion tunnel and subshell in parallel, cancelling the other if one completes.""" + + task1 = asyncio.create_task( + _aks_bastion_launch_tunnel(bastion_resource, port, mc_id) + ) + if test_hook: + task2 = asyncio.create_task( + _aks_bastion_test_hook(kubeconfig_path, port, kubectl_path=test_hook) + ) + else: + task2 = asyncio.create_task(_aks_bastion_launch_subshell(kubeconfig_path, port)) + + _, pending = await asyncio.wait([task1, task2], return_when=asyncio.FIRST_COMPLETED) + + for task in pending: + task.cancel() + + # Wait for the cancellations to finish + await asyncio.gather(*pending, return_exceptions=True) + + +def aks_batsion_clean_up(): + pass + + +def _aks_bastion_get_az_cmd_name(): + """Get the name of the az command based on system platform.""" + + if sys.platform.startswith("win"): + return "az.cmd" + return "az" + + +def _aks_bastion_get_current_shell_cmd(): + """Get the current shell command being used by the parent process.""" + + ppid = os.getppid() + parent = psutil.Process(ppid) + return parent.name() + + +def _aks_bastion_prepare_shell_cmd(kubeconfig_path): + """Prepare the shell command to launch a subshell with KUBECONFIG set.""" + + shell_cmd = _aks_bastion_get_current_shell_cmd() + updated_shell_cmd = shell_cmd + if shell_cmd.endswith("bash") and os.path.exists(os.path.expanduser("~/.bashrc")): + updated_shell_cmd = ( + f"""{shell_cmd} -c '{shell_cmd} --rcfile <(cat ~/.bashrc; """ + f"""echo "export KUBECONFIG={kubeconfig_path}")'""" + ) + return shell_cmd, updated_shell_cmd + + +def _aks_bastion_restore_shell(shell_cmd): + """Restore the shell settings after the subshell exits.""" + + if shell_cmd.endswith("bash"): + subprocess.run(["stty", "sane"], stdin=sys.stdin) + + +async def _aks_bastion_launch_subshell(kubeconfig_path, port): + """Launch a subshell with the KUBECONFIG environment variable set to the provided path.""" + subshell_process = None + try: + if await _aks_bastion_validate_tunnel(port): + logger.debug("Bastion tunnel is set up successfully.") + else: + raise CLIInternalError( + f"Bastion tunnel failed to set up on port {port}. Please check the logs for more details." + ) + + env = os.environ.copy() + env.update({"KUBECONFIG": kubeconfig_path}) + shell_cmd, updated_shell_cmd = _aks_bastion_prepare_shell_cmd(kubeconfig_path) + logger.warning( + "Launching subshell with command '%s'. Setting env var KUBECONFIG to '%s'.", + updated_shell_cmd, + kubeconfig_path, + ) + logger.warning( + "Use exit or Ctrl-D (i.e. EOF) to exit the subshell." + ) + subshell_process = await asyncio.subprocess.create_subprocess_shell( + cmd=updated_shell_cmd, + stdin=None, + stdout=None, + stderr=None, + shell=True, + env=env, + ) + logger.info("Subshell launched with PID: %s", subshell_process.pid) + + # subshell process must not exit unless it encounters a failure or is deliberately shut down + await subshell_process.wait() + logger.debug("Subshell exited with code: %s", subshell_process.returncode) + except asyncio.CancelledError: + # attempt to terminate the subshell process gracefully + if subshell_process is not None: + logger.info("Subshell was cancelled. Terminating...") + subshell_process.terminate() + try: + await asyncio.wait_for(subshell_process.wait(), timeout=5) + logger.info("Subshell exited cleanly after termination.") + except asyncio.TimeoutError: + logger.warning( + "Subshell did not exit after SIGTERM. Sending SIGKILL..." + ) + subshell_process.kill() + await asyncio.wait_for(subshell_process.wait(), timeout=5) + logger.warning( + "Subshell forcefully killed with code %s", + subshell_process.returncode, + ) + _aks_bastion_restore_shell(shell_cmd) + else: + logger.warning("Subshell was cancelled before it could be launched.") + + +async def _aks_bastion_launch_tunnel(bastion_resource, port, mc_id): + """Launch the bastion tunnel using the provided parameters.""" + + tunnel_proces = None + try: + az_cmd_name = _aks_bastion_get_az_cmd_name() + cmd = ( + f"{az_cmd_name} network bastion tunnel --resource-group {bastion_resource.resource_group} " + f"--name {bastion_resource.name} --port {port} --target-resource-id {mc_id} --resource-port 443" + ) + logger.warning("Creating bastion tunnel with command: '%s'", cmd) + tunnel_proces = await asyncio.create_subprocess_exec( + *(cmd.split()), + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + shell=False, + ) + logger.info("Tunnel launched with PID: %s", tunnel_proces.pid) + + # tunnel process must not exit unless it encounters a failure or is deliberately shut down + await tunnel_proces.wait() + logger.error("Bastion tunnel exited with code %s", tunnel_proces.returncode) + except asyncio.CancelledError: + # attempt to terminate the tunnel process gracefully + if tunnel_proces is not None: + logger.info("Tunnel process was cancelled. Terminating...") + tunnel_proces.terminate() + try: + await asyncio.wait_for(tunnel_proces.wait(), timeout=5) + logger.info("Tunnel process exited cleanly after termination.") + except asyncio.TimeoutError: + logger.warning( + "Tunnel process did not exit after SIGTERM. Sending SIGKILL..." + ) + tunnel_proces.kill() + await asyncio.wait_for(tunnel_proces.wait(), timeout=5) + logger.warning( + "Tunnel process forcefully killed with code %s", + tunnel_proces.returncode, + ) + else: + logger.warning("Tunnel process was cancelled before it could be launched.") + + +async def _aks_bastion_validate_tunnel(port): + """Check if the bastion tunnel is active on the specified port.""" + # give the tunnel some time to establish before checking the port + await asyncio.sleep(5) + + # retry for up to 5 times to check if the port is open + for attempt in range(5): + logger.debug( + "Checking if tunnel is active on port %s (attempt %d)...", port, attempt + 1 + ) + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(5) + try: + s.connect(("localhost", port)) + logger.info("Tunnel is active on port %s", port) + return True + except socket.error as e: + logger.warning( + "Attempt %d: Tunnel is not active on port %s: %s", + attempt + 1, + port, + e, + ) + await asyncio.sleep(5) + logger.error("Tunnel failed to become active on port %s after 5 attempts.", port) + return False + + +async def _aks_bastion_test_hook(kubeconfig_path, port, kubectl_path): + """Test hook to validate the bastion tunnel and run a kubectl command.""" + if not await _aks_bastion_validate_tunnel(port): + raise CLIInternalError(f"Bastion tunnel failed to set up on port {port}.") + kubectl_process = await asyncio.create_subprocess_shell( + f"{kubectl_path} --kubeconfig {kubeconfig_path} get nodes", + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + shell=True, + ) + await asyncio.wait_for(kubectl_process.wait(), timeout=10) + if kubectl_process.returncode != 0: + raise CLIInternalError( + f"Command kubectl failed with return code {kubectl_process.returncode}. " + ) diff --git a/src/aks-preview/azext_aks_preview/commands.py b/src/aks-preview/azext_aks_preview/commands.py index f0a5eadde61..3a817dd7997 100644 --- a/src/aks-preview/azext_aks_preview/commands.py +++ b/src/aks-preview/azext_aks_preview/commands.py @@ -186,6 +186,7 @@ def load_command_table(self, _): g.custom_command( "operation-abort", "aks_operation_abort", supports_no_wait=True ) + g.custom_command("bastion", "aks_bastion") # AKS maintenance configuration commands with self.command_group( diff --git a/src/aks-preview/azext_aks_preview/custom.py b/src/aks-preview/azext_aks_preview/custom.py index efa44e4da22..324cf4e6acf 100644 --- a/src/aks-preview/azext_aks_preview/custom.py +++ b/src/aks-preview/azext_aks_preview/custom.py @@ -93,6 +93,14 @@ aks_draft_cmd_up, aks_draft_cmd_update, ) +from azext_aks_preview.bastion.bastion import ( + aks_bastion_parse_bastion_resource, + aks_bastion_get_local_port, + aks_bastion_extension, + aks_bastion_set_kubeconfig, + aks_bastion_runner, + aks_batsion_clean_up +) from azext_aks_preview.maintenanceconfiguration import ( aks_maintenanceconfiguration_update_internal, ) @@ -4339,3 +4347,39 @@ def aks_loadbalancer_rebalance_nodes( } return aks_loadbalancer_rebalance_internal(managed_clusters_client, parameters) + + +def aks_bastion(cmd, client, resource_group_name, name, bastion=None, port=None, admin=False, yes=False): + import asyncio + import tempfile + + from azure.cli.command_modules.acs._consts import DecoratorEarlyExitException + + try: + aks_bastion_extension(yes) + except DecoratorEarlyExitException as ex: + logger.error(ex) + return + + with tempfile.TemporaryDirectory() as temp_dir: + logger.info("creating temporary directory: %s", temp_dir) + try: + kubeconfig_path = os.path.join(temp_dir, ".kube", "config") + mc = client.get(resource_group_name, name) + mc_id = mc.id + nrg = mc.node_resource_group + bastion_resource = aks_bastion_parse_bastion_resource(bastion, [nrg]) + port = aks_bastion_get_local_port(port) + aks_get_credentials(cmd, client, resource_group_name, name, admin=admin, path=kubeconfig_path) + aks_bastion_set_kubeconfig(kubeconfig_path, port) + asyncio.run( + aks_bastion_runner( + bastion_resource, + port, + mc_id, + kubeconfig_path, + test_hook=os.getenv("AKS_BASTION_TEST_HOOK"), + ) + ) + finally: + aks_batsion_clean_up() diff --git a/src/aks-preview/azext_aks_preview/tests/latest/test_aks_commands.py b/src/aks-preview/azext_aks_preview/tests/latest/test_aks_commands.py index 5cc4ecaa2aa..27f170df739 100644 --- a/src/aks-preview/azext_aks_preview/tests/latest/test_aks_commands.py +++ b/src/aks-preview/azext_aks_preview/tests/latest/test_aks_commands.py @@ -23,9 +23,9 @@ use_shared_identity, ) from azure.cli.core.azclierror import ClientRequestError -from azure.core.exceptions import (HttpResponseError) from azure.cli.testsdk import CliTestError, ScenarioTest, live_only from azure.cli.testsdk.scenario_tests import AllowLargeResponse +from azure.core.exceptions import HttpResponseError from knack.util import CLIError from .test_localdns_profile import assert_dns_overrides_equal, vnetDnsOverridesExpected, kubeDnsOverridesExpected @@ -181,6 +181,21 @@ def _get_asm_upgrade_version(self, resource_group, name): sorted_upgrades = self._sort_revisions(res["upgrades"]) return sorted_upgrades[0] + def _verify_kubectl_installation(self) -> bool: + """Verify if kubectl is installed and accessible.""" + try: + subprocess.run( + ["kubectl", "version", "--client"], + check=True, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + shell=False, + ) + return True + except subprocess.CalledProcessError: + return False + @classmethod def generate_ssh_keys(cls): # If the `--ssh-key-value` option is not specified, the validator will try to read the ssh-key from the "~/.ssh" directory, @@ -11254,11 +11269,12 @@ def test_aks_kollect_with_managed_aad( sp_oid = self._get_test_identity_object_id() print(f"objectid of service principal is {sp_oid}") - # Install kubectl (for setting up service principal permissions, and required by the 'kollect' command). - try: - subprocess.call(["az", "aks", "install-cli"]) - except subprocess.CalledProcessError as err: - raise CliTestError(f"Failed to install kubectl with error: '{err}'") + if not self._verify_kubectl_installation(): + # Install kubectl (for setting up service principal permissions, and required by the 'kollect' command). + try: + subprocess.call(["az", "aks", "install-cli"]) + except subprocess.CalledProcessError as err: + raise CliTestError(f"Failed to install kubectl with error: '{err}'") # Grant the service principal cluster-admin access using the admin account # (it'd be nice if `az aks command invoke` had an --admin option, but it appears not to, so we have to download admin credentials) @@ -12985,7 +13001,7 @@ def test_aks_azure_service_mesh_with_egress_gateway( try: subprocess.call(["az", "aks", "install-cli"]) except subprocess.CalledProcessError as err: - raise CLITestError("Failed to install kubectl with error: '{}'!".format(err)) + raise CliTestError("Failed to install kubectl with error: '{}'!".format(err)) try: # get credential @@ -13023,7 +13039,7 @@ def test_aks_azure_service_mesh_with_egress_gateway( stderr=subprocess.STDOUT, ) if not f"namespace/{istio_egress_namespace} created" in k_create_sgc_namespace_output: - raise CLITestError(f"failed to create istio egress gateway namespace: {istio_egress_namespace}") + raise CliTestError(f"failed to create istio egress gateway namespace: {istio_egress_namespace}") k_create_sgc_command = ["kubectl", "apply", "-f", sgc_browse_path, "--kubeconfig", browse_path] k_create_sgc_output = subprocess.check_output( @@ -13032,7 +13048,7 @@ def test_aks_azure_service_mesh_with_egress_gateway( stderr=subprocess.STDOUT, ) if not f"staticgatewayconfiguration.egressgateway.kubernetes.azure.com/{istio_sgc_name} created" in k_create_sgc_output: - raise CLITestError("failed to create StaticGatewayConfiguration") + raise CliTestError("failed to create StaticGatewayConfiguration") finally: # Delete files if os.path.exists(browse_path): @@ -16634,6 +16650,73 @@ def test_aks_extension_type_backup(self, resource_group, resource_group_location # delete the extension self.cmd('aks extension delete -g {rg} -c {cluster_name} -n {name} --force -y') + # live only, otherwise the current recording mechanism will also record the binary files of + # kubectl and kubelogin resulting in the cassette file size exceeding 100MB + @live_only() + @AllowLargeResponse() + @AKSCustomResourceGroupPreparer( + random_name_length=17, name_prefix="clitest", location="eastus" + ) + def test_aks_bastion(self, resource_group, resource_group_location): + aks_name = self.create_random_name("cliakstest", 16) + self.kwargs.update( + { + "resource_group": resource_group, + "name": aks_name, + "location": resource_group_location, + "ssh_key_value": self.generate_ssh_keys(), + } + ) + + # create private cluster + create_cmd = ( + "aks create --resource-group={resource_group} --name={name} " + "--node-count=1 --enable-private-cluster " + "--ssh-key-value={ssh_key_value}" + ) + mc = self.cmd( + create_cmd, + checks=[ + self.exists("privateFqdn"), + self.check("provisioningState", "Succeeded"), + ], + ).get_output_in_json() + nrg = mc["nodeResourceGroup"] + + # create bastion + list_vnet_cmd = f"network vnet list -g {nrg} -o json" + vnets = self.cmd(list_vnet_cmd).get_output_in_json() + vnet_name = vnets[0]["name"] + + create_subnet_cmd = f"network vnet subnet create --resource-group {nrg} " \ + f"--vnet-name {vnet_name} --name AzureBastionSubnet " \ + f"--address-prefixes 10.238.0.0/16" + self.cmd(create_subnet_cmd, checks=[self.check("provisioningState", "Succeeded")]) + + create_pip_cmd = f"network public-ip create -g {nrg} -n aks-bastion-pip --sku Standard" + self.cmd(create_pip_cmd) + + subprocess.run(["az", "extension", "add", "--name", "bastion", "--yes"], check=True) + + create_bastion_cmd = f"network bastion create -g {nrg} -n aks-bastion " \ + f"--public-ip-address aks-bastion-pip " \ + f"--vnet-name {vnet_name} --enable-tunneling" + self.cmd(create_bastion_cmd, checks=[self.check("provisioningState", "Succeeded")]) + + kubectl_path = "kubectl" + if not self._verify_kubectl_installation(): + # install kubectl + _, kubectl_path = tempfile.mkstemp() + _, login_temp_file = tempfile.mkstemp() + version = "latest" + install_cmd = 'aks install-cli --client-version={} --install-location={} --base-src-url={} ' \ + '--kubelogin-version={} --kubelogin-install-location={} --kubelogin-base-src-url={}'.format(version, kubectl_path, "", version, login_temp_file, "") + self.cmd(install_cmd, checks=[self.is_empty()]) + + # test bastion connectivity + os.environ["AKS_BASTION_TEST_HOOK"] = kubectl_path + bastion_cmd = f"aks bastion -g {resource_group} -n {aks_name}" + self.cmd(bastion_cmd, checks=[self.is_empty()]) @AllowLargeResponse() @AKSCustomResourceGroupPreparer( diff --git a/src/aks-preview/setup.py b/src/aks-preview/setup.py index dec6848b5fe..2c60cf4d0e0 100644 --- a/src/aks-preview/setup.py +++ b/src/aks-preview/setup.py @@ -9,7 +9,7 @@ from setuptools import setup, find_packages -VERSION = "18.0.0b20" +VERSION = "18.0.0b21" CLASSIFIERS = [ "Development Status :: 4 - Beta",