Skip to content

Commit b3bb9fe

Browse files
authored
[AKS] az aks bastion: Add new option --kubeconfig-path to allow users to specify an existing kubeconfig file (#9509)
1 parent 19e3d4a commit b3bb9fe

7 files changed

Lines changed: 165 additions & 37 deletions

File tree

src/aks-preview/HISTORY.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ To release a new version, please select a new version number (usually plus 1 to
1212
Pending
1313
+++++++
1414

15+
19.0.0b20
16+
+++++++
17+
* `az aks bastion`: Add new option `--kubeconfig-path` to allow users to specify an existing kubeconfig file
1518

1619
19.0.0b19
1720
+++++++

src/aks-preview/azext_aks_preview/_help.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4227,9 +4227,15 @@
42274227
- name: --admin
42284228
type: bool
42294229
short-summary: Use the cluster admin credentials to connect to the bastion.
4230+
- name: --kubeconfig-path
4231+
type: string
4232+
short-summary: Path to an existing kubeconfig file to use.
4233+
long-summary: If specified, uses this kubeconfig file at its original location instead of fetching credentials from Azure.
42304234
examples:
42314235
- name: Connect to a managed Kubernetes cluster using Azure Bastion with custom port and admin credentials.
42324236
text: az aks bastion -g MyResourceGroup --name MyManagedCluster --bastion MyBastionResource --port 50001 --admin
4237+
- name: Connect using an existing kubeconfig file.
4238+
text: az aks bastion -g MyResourceGroup --name MyManagedCluster --kubeconfig-path ~/.kube/config
42334239
"""
42344240

42354241
helps['aks identity-binding'] = """

src/aks-preview/azext_aks_preview/_params.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3108,6 +3108,7 @@ def load_arguments(self, _):
31083108
c.argument("bastion")
31093109
c.argument("port", type=int)
31103110
c.argument("admin", action="store_true")
3111+
c.argument("kubeconfig_path")
31113112
c.argument(
31123113
"yes",
31133114
options_list=["--yes", "-y"],

src/aks-preview/azext_aks_preview/bastion/bastion.py

Lines changed: 100 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import asyncio
77
import os
88
import shutil
9+
import signal
910
import socket
1011
import subprocess
1112
import sys
@@ -179,27 +180,64 @@ def aks_bastion_extension(yes):
179180
raise CLIInternalError(f"Failed to install bastion extension: {result.error}")
180181

181182

182-
def aks_bastion_set_kubeconfig(kubeconfig_path, port):
183-
"""Update the kubeconfig file to point to the local port."""
183+
def aks_bastion_set_kubeconfig(kubeconfig_path, port, cluster_name=None):
184+
"""Update the kubeconfig file to point to the local port.
185+
186+
Args:
187+
kubeconfig_path: Path to the kubeconfig file
188+
port: Local port for the bastion tunnel
189+
cluster_name: Name of the AKS cluster. If provided, searches for exact match in existing kubeconfig.
190+
If not provided, uses current context (for newly downloaded kubeconfigs).
191+
"""
184192

185193
logger.debug("Updating kubeconfig file: %s to use port: %s", kubeconfig_path, port)
186194
with open(kubeconfig_path, "r") as f:
187195
data = yaml.load(f, Loader=yaml.SafeLoader)
188-
current_context = data["current-context"]
189-
current_cluster = ""
190-
for context in data["contexts"]:
191-
if context["name"] == current_context:
192-
current_cluster = context["context"]["cluster"]
193-
194-
for cluster in data["clusters"]:
195-
if cluster["name"] == current_cluster:
196+
197+
# Find the target cluster
198+
target_cluster_name = None
199+
200+
if cluster_name:
201+
# For existing kubeconfigs, search for exact match in clusters
202+
logger.debug("Searching for cluster '%s' in existing kubeconfig", cluster_name)
203+
204+
for cluster in data.get("clusters", []):
205+
if cluster["name"] == cluster_name:
206+
target_cluster_name = cluster_name
207+
logger.debug("Found exact match for cluster name: %s", target_cluster_name)
208+
break
209+
210+
if not target_cluster_name:
211+
raise CLIInternalError(
212+
f"Could not find cluster '{cluster_name}' in the provided kubeconfig. "
213+
"The cluster name from Azure might differ from the name in your kubeconfig file."
214+
)
215+
else:
216+
# If cluster_name not provided, use current context
217+
current_context = data.get("current-context")
218+
if current_context:
219+
for context in data.get("contexts", []):
220+
if context["name"] == current_context:
221+
target_cluster_name = context["context"]["cluster"]
222+
logger.debug("Using current context cluster: %s", target_cluster_name)
223+
break
224+
225+
if not target_cluster_name:
226+
raise CLIInternalError("Could not determine which cluster to update in kubeconfig")
227+
228+
# Update the cluster configuration
229+
for cluster in data.get("clusters", []):
230+
if cluster["name"] == target_cluster_name:
196231
server = cluster["cluster"]["server"]
197232
hostname = urlparse(server).hostname
198233
# update the server URL to point to the local port
199234
cluster["cluster"]["server"] = f"https://localhost:{port}/"
200235
# set the tls-server-name to the hostname
201236
cluster["cluster"]["tls-server-name"] = hostname
237+
logger.debug("Updated cluster '%s' to use localhost:%s with tls-server-name=%s",
238+
target_cluster_name, port, hostname)
202239
break
240+
203241
with open(kubeconfig_path, "w") as f:
204242
yaml.dump(data, f)
205243

@@ -441,31 +479,45 @@ async def _aks_bastion_launch_tunnel(bastion_resource, port, mc_id):
441479
f"--name {bastion_resource.name} --port {port} --target-resource-id {mc_id} --resource-port 443"
442480
)
443481
logger.warning("Creating bastion tunnel with command: '%s'", cmd)
482+
483+
# Use start_new_session on Unix to create a new process group
484+
# This allows us to kill the entire process tree when cleaning up
485+
start_new_session = not sys.platform.startswith("win")
444486
tunnel_proces = await asyncio.create_subprocess_exec(
445487
*(cmd.split()),
446488
stdin=asyncio.subprocess.DEVNULL,
447489
stdout=asyncio.subprocess.DEVNULL,
448490
stderr=asyncio.subprocess.DEVNULL,
449491
shell=False,
492+
start_new_session=start_new_session,
450493
)
451494
logger.info("Tunnel launched with PID: %s", tunnel_proces.pid)
452495

453496
# tunnel process must not exit unless it encounters a failure or is deliberately shut down
454497
await tunnel_proces.wait()
455498
logger.error("Bastion tunnel exited with code %s", tunnel_proces.returncode)
456499
except asyncio.CancelledError:
457-
# attempt to terminate the tunnel process gracefully
500+
# attempt to terminate the tunnel process and all its children
458501
if tunnel_proces is not None:
459-
logger.info("Tunnel process was cancelled. Terminating...")
460-
tunnel_proces.terminate()
502+
logger.info("Tunnel process was cancelled. Terminating process tree...")
503+
_aks_bastion_kill_process_tree(tunnel_proces)
461504
try:
462505
await asyncio.wait_for(tunnel_proces.wait(), timeout=5)
463506
logger.info("Tunnel process exited cleanly after termination.")
464507
except asyncio.TimeoutError:
465508
logger.warning(
466-
"Tunnel process did not exit after SIGTERM. Sending SIGKILL..."
509+
"Tunnel process did not exit after SIGTERM. Force killing..."
467510
)
468-
tunnel_proces.kill()
511+
if sys.platform.startswith("win"):
512+
# On Windows, taskkill /F should have already force-killed
513+
# but try again with kill() as fallback
514+
tunnel_proces.kill()
515+
else:
516+
# On Unix, send SIGKILL to the process group
517+
try:
518+
os.killpg(os.getpgid(tunnel_proces.pid), signal.SIGKILL)
519+
except (ProcessLookupError, PermissionError):
520+
tunnel_proces.kill()
469521
await asyncio.wait_for(tunnel_proces.wait(), timeout=5)
470522
logger.warning(
471523
"Tunnel process forcefully killed with code %s",
@@ -475,6 +527,39 @@ async def _aks_bastion_launch_tunnel(bastion_resource, port, mc_id):
475527
logger.warning("Tunnel process was cancelled before it could be launched.")
476528

477529

530+
def _aks_bastion_kill_process_tree(process):
531+
"""Kill a process and all its children.
532+
533+
On Windows, az.cmd spawns a child Python process, so we need to kill the entire
534+
process tree to avoid orphaned processes.
535+
"""
536+
if process is None:
537+
return
538+
539+
pid = process.pid
540+
if sys.platform.startswith("win"):
541+
# On Windows, use taskkill with /T flag to kill the process tree
542+
try:
543+
subprocess.run(
544+
["taskkill", "/T", "/F", "/PID", str(pid)],
545+
capture_output=True,
546+
check=False,
547+
)
548+
logger.debug("Killed process tree for PID %s using taskkill", pid)
549+
except Exception as e: # pylint: disable=broad-except
550+
logger.warning("Failed to kill process tree with taskkill: %s", e)
551+
# Fallback to terminate/kill
552+
process.terminate()
553+
else:
554+
# On Unix, kill the process group
555+
try:
556+
os.killpg(os.getpgid(pid), signal.SIGTERM)
557+
logger.debug("Sent SIGTERM to process group for PID %s", pid)
558+
except (ProcessLookupError, PermissionError) as e:
559+
logger.debug("Failed to kill process group: %s", e)
560+
process.terminate()
561+
562+
478563
async def _aks_bastion_validate_tunnel(port):
479564
"""Check if the bastion tunnel is active on the specified port."""
480565
# give the tunnel some time to establish before checking the port

src/aks-preview/azext_aks_preview/custom.py

Lines changed: 53 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5082,7 +5082,7 @@ def aks_loadbalancer_rebalance_nodes(
50825082
return aks_loadbalancer_rebalance_internal(managed_clusters_client, parameters)
50835083

50845084

5085-
def aks_bastion(cmd, client, resource_group_name, name, bastion=None, port=None, admin=False, yes=False):
5085+
def aks_bastion(cmd, client, resource_group_name, name, bastion=None, port=None, admin=False, kubeconfig_path=None, yes=False):
50865086
import asyncio
50875087
import tempfile
50885088

@@ -5094,28 +5094,61 @@ def aks_bastion(cmd, client, resource_group_name, name, bastion=None, port=None,
50945094
logger.error(ex)
50955095
return
50965096

5097-
with tempfile.TemporaryDirectory() as temp_dir:
5097+
temp_dir = None
5098+
# Validate kubeconfig if provided, otherwise create temp
5099+
if kubeconfig_path:
5100+
if not os.path.exists(kubeconfig_path):
5101+
raise CLIError(f"Kubeconfig file '{kubeconfig_path}' does not exist.")
5102+
logger.info("Using kubeconfig from: %s", kubeconfig_path)
5103+
else:
5104+
temp_dir = tempfile.mkdtemp()
50985105
logger.info("creating temporary directory: %s", temp_dir)
5099-
try:
5100-
kubeconfig_path = os.path.join(temp_dir, ".kube", "config")
5101-
mc = client.get(resource_group_name, name)
5102-
mc_id = mc.id
5103-
nrg = mc.node_resource_group
5104-
bastion_resource = aks_bastion_parse_bastion_resource(bastion, [nrg])
5105-
port = aks_bastion_get_local_port(port)
5106+
kubeconfig_path = os.path.join(temp_dir, ".kube", "config")
5107+
5108+
try:
5109+
mc = client.get(resource_group_name, name)
5110+
mc_id = mc.id
5111+
nrg = mc.node_resource_group
5112+
bastion_resource = aks_bastion_parse_bastion_resource(bastion, [nrg])
5113+
port = aks_bastion_get_local_port(port)
5114+
5115+
# Fetch credentials only if kubeconfig not provided
5116+
is_new_kubeconfig = temp_dir is not None
5117+
if is_new_kubeconfig:
51065118
aks_get_credentials(cmd, client, resource_group_name, name, admin=admin, path=kubeconfig_path)
5107-
aks_bastion_set_kubeconfig(kubeconfig_path, port)
5108-
asyncio.run(
5109-
aks_bastion_runner(
5110-
bastion_resource,
5111-
port,
5112-
mc_id,
5113-
kubeconfig_path,
5114-
test_hook=os.getenv("AKS_BASTION_TEST_HOOK"),
5115-
)
5119+
5120+
# Pass cluster_name only for existing kubeconfigs to search for exact match
5121+
# For new kubeconfigs, don't pass cluster_name so it uses current context
5122+
aks_bastion_set_kubeconfig(
5123+
kubeconfig_path,
5124+
port,
5125+
cluster_name=name if not is_new_kubeconfig else None
5126+
)
5127+
5128+
# Warn user about kubeconfig modifications after successful modification
5129+
if not is_new_kubeconfig:
5130+
logger.warning(
5131+
"The server URL for cluster '%s' in your kubeconfig has been modified to point to the bastion tunnel. "
5132+
"Once the bastion tunnel is closed, this cluster configuration will no longer work. "
5133+
"To re-establish connectivity via bastion, rerun this command; this will update the server URL in your kubeconfig to point to a new bastion tunnel. "
5134+
"If you no longer want to use the bastion tunnel, restore your original kubeconfig from backup instead.",
5135+
name
51165136
)
5117-
finally:
5118-
aks_batsion_clean_up()
5137+
5138+
asyncio.run(
5139+
aks_bastion_runner(
5140+
bastion_resource,
5141+
port,
5142+
mc_id,
5143+
kubeconfig_path,
5144+
test_hook=os.getenv("AKS_BASTION_TEST_HOOK"),
5145+
)
5146+
)
5147+
finally:
5148+
aks_batsion_clean_up()
5149+
if temp_dir:
5150+
import shutil
5151+
shutil.rmtree(temp_dir, ignore_errors=True)
51195152

51205153

51215154
aks_identity_binding_create = aks_ib_cmd_create

src/aks-preview/azext_aks_preview/tests/latest/test_aks_commands.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20659,7 +20659,7 @@ def test_aks_bastion(self, resource_group, resource_group_location):
2065920659

2066020660
create_subnet_cmd = f"network vnet subnet create --resource-group {nrg} " \
2066120661
f"--vnet-name {vnet_name} --name AzureBastionSubnet " \
20662-
f"--address-prefixes 10.238.0.0/16"
20662+
f"--address-prefixes 10.225.0.0/26"
2066320663
self.cmd(create_subnet_cmd, checks=[self.check("provisioningState", "Succeeded")])
2066420664

2066520665
create_pip_cmd = f"network public-ip create -g {nrg} -n aks-bastion-pip --sku Standard"

src/aks-preview/setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from setuptools import find_packages, setup
1111

12-
VERSION = "19.0.0b19"
12+
VERSION = "19.0.0b20"
1313

1414
CLASSIFIERS = [
1515
"Development Status :: 4 - Beta",

0 commit comments

Comments
 (0)