Skip to content

Commit befe612

Browse files
Wei WengWei Weng
authored andcommitted
fleet kubeconfig auto-conversion with kubelogin
Signed-off-by: Wei Weng <Wei.Weng@microsoft.com>
1 parent 4496952 commit befe612

File tree

7 files changed

+265
-6
lines changed

7 files changed

+265
-6
lines changed

src/fleet/HISTORY.rst

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,4 +168,8 @@ Release History
168168

169169
1.8.2
170170
++++++
171-
* Fix fleet namespace get-credentials command with fleet member parameter.
171+
* Fix fleet namespace get-credentials command with fleet member parameter.
172+
173+
1.8.3
174+
++++++
175+
* Add automatic kubelogin conversion to Azure CLI authentication for fleet get-credentials command.

src/fleet/azext_fleet/_helpers.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,17 @@
2525
logger = get_logger(__name__)
2626

2727

28+
def is_stdout_path(path):
29+
"""Check if the path represents stdout."""
30+
return path == "-"
31+
32+
2833
def print_or_merge_credentials(path, kubeconfig, overwrite_existing, context_name):
2934
"""Merge an unencrypted kubeconfig into the file at the specified path, or print it to
3035
stdout if the path is "-".
3136
"""
3237
# Special case for printing to stdout
33-
if path == "-":
38+
if is_stdout_path(path):
3439
print(kubeconfig)
3540
return
3641

src/fleet/azext_fleet/_params.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ def load_arguments(self, _):
6565
c.argument('context_name', options_list=['--context'], help='If specified, overwrite the default context name.')
6666
c.argument('path', options_list=['--file', '-f'], type=file_type, completer=FilesCompleter(),
6767
default=os.path.join(os.path.expanduser('~'), '.kube', 'config'))
68+
c.ignore('skip_kubelogin_conversion') # Internal parameter, not exposed to users
6869

6970
with self.argument_context('fleet member') as c:
7071
c.argument('name', options_list=['--name', '-n'], help='Specify the fleet member name.')

src/fleet/azext_fleet/custom.py

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
# --------------------------------------------------------------------------------------------
55

66
import os
7-
import yaml
7+
import shutil
8+
import subprocess
89
import tempfile
10+
import yaml
911

1012
from knack.log import get_logger
1113
from knack.util import CLIError
@@ -20,6 +22,7 @@
2022
from azext_fleet._helpers import is_rp_registered, print_or_merge_credentials
2123
from azext_fleet._helpers import assign_network_contributor_role_to_subnet
2224
from azext_fleet._helpers import get_msi_object_id
25+
from azext_fleet._helpers import is_stdout_path
2326
from azext_fleet.constants import UPGRADE_TYPE_CONTROLPLANEONLY
2427
from azext_fleet.constants import UPGRADE_TYPE_FULL
2528
from azext_fleet.constants import UPGRADE_TYPE_NODEIMAGEONLY
@@ -218,14 +221,52 @@ def delete_fleet(cmd, # pylint: disable=unused-argument
218221
return sdk_no_wait(no_wait, client.begin_delete, resource_group_name, name, polling_interval=5)
219222

220223

224+
def _convert_kubeconfig_to_azurecli(path):
225+
"""
226+
Convert kubeconfig to use Azure CLI authentication if it uses devicecode.
227+
228+
Args:
229+
path: Path to the kubeconfig file to convert
230+
"""
231+
# Skip conversion if path is stdout
232+
if is_stdout_path(path):
233+
return
234+
235+
if shutil.which("kubelogin"):
236+
try:
237+
subprocess.run(
238+
["kubelogin", "convert-kubeconfig", "-l", "azurecli", "--kubeconfig", path],
239+
check=True,
240+
capture_output=True,
241+
text=True,
242+
timeout=60,
243+
)
244+
logger.warning("Converted kubeconfig to use Azure CLI authentication.")
245+
except subprocess.CalledProcessError as e:
246+
logger.warning("Failed to convert kubeconfig with kubelogin: %s", str(e))
247+
except subprocess.TimeoutExpired as e:
248+
logger.warning("kubelogin command timed out: %s", str(e))
249+
except Exception as e: # pylint: disable=broad-except
250+
logger.warning("Error running kubelogin: %s", str(e))
251+
else:
252+
logger.warning(
253+
"The fleet hub cluster kubeconfig requires kubelogin. "
254+
"Please install kubelogin from https://github.com/Azure/kubelogin or run "
255+
"'az aks install-cli' to install both kubectl and kubelogin. "
256+
"After installing kubelogin, rerun 'az fleet get-credentials' and the "
257+
"kubeconfig will be converted automatically."
258+
)
259+
260+
221261
def get_credentials(cmd,
222262
client,
223263
resource_group_name,
224264
name,
225265
path=os.path.join(os.path.expanduser('~'), '.kube', 'config'),
226266
overwrite_existing=False,
227267
context_name=None,
228-
member_name=None):
268+
member_name=None,
269+
skip_kubelogin_conversion=False):
229270

230271
# If a member name is given, we use the cluster resource ID from the fleet member
231272
# to get that member cluster's credentials
@@ -273,6 +314,11 @@ def get_credentials(cmd,
273314
try:
274315
kubeconfig = credential_results.kubeconfigs[0].value.decode(encoding='UTF-8')
275316
print_or_merge_credentials(path, kubeconfig, overwrite_existing, context_name)
317+
# Fleet hub is always RBAC-enabled and should convert it with kubelogin so that
318+
# user doesn't have to manually run kubelogin convert-kubeconfig -l azurecli
319+
# every time after az fleet get-credentials
320+
if not skip_kubelogin_conversion:
321+
_convert_kubeconfig_to_azurecli(path)
276322
except (IndexError, ValueError) as exc:
277323
raise CLIError("Fail to find kubeconfig file.") from exc
278324

@@ -985,7 +1031,8 @@ def get_namespace_credentials(cmd,
9851031
path=temp_file.name,
9861032
overwrite_existing=overwrite_existing,
9871033
context_name=context_name,
988-
member_name=member_name
1034+
member_name=member_name,
1035+
skip_kubelogin_conversion=True # Skip here, we'll convert after namespace modification
9891036
)
9901037

9911038
with open(temp_file.name, 'r', encoding='utf-8') as f:
@@ -998,3 +1045,6 @@ def get_namespace_credentials(cmd,
9981045
modified_kubeconfig = yaml.dump(kubeconfig, default_flow_style=False)
9991046
print_or_merge_credentials(path, modified_kubeconfig, overwrite_existing, context_name)
10001047
print(f"Default namespace set to '{managed_namespace_name}' for context '{kubeconfig.get('current-context')}'")
1048+
1049+
# Apply kubelogin conversion to the final file after namespace modification
1050+
_convert_kubeconfig_to_azurecli(path)
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# --------------------------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for license information.
4+
# --------------------------------------------------------------------------------------------
5+
6+
import unittest
7+
from azext_fleet._helpers import is_stdout_path
8+
9+
10+
# Test constants
11+
STDOUT_PATH = '-'
12+
SAMPLE_KUBECONFIG_PATH = "/home/user/.kube/config"
13+
SAMPLE_CONFIG_FILENAME = "config.yaml"
14+
15+
16+
class TestHelpers(unittest.TestCase):
17+
"""Test cases for helper functions."""
18+
19+
def test_is_stdout_path_with_dash(self):
20+
"""Test that '-' is recognized as stdout path."""
21+
self.assertTrue(is_stdout_path(STDOUT_PATH))
22+
23+
def test_is_stdout_path_with_regular_path(self):
24+
"""Test that regular paths are not recognized as stdout."""
25+
self.assertFalse(is_stdout_path(SAMPLE_KUBECONFIG_PATH))
26+
self.assertFalse(is_stdout_path(SAMPLE_CONFIG_FILENAME))
27+
28+
29+
if __name__ == '__main__':
30+
unittest.main()
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
# --------------------------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for license information.
4+
# --------------------------------------------------------------------------------------------
5+
6+
import unittest
7+
import tempfile
8+
import os
9+
from unittest.mock import patch, MagicMock, call
10+
from azext_fleet.custom import _convert_kubeconfig_to_azurecli
11+
12+
13+
# Test constants
14+
KUBELOGIN_BINARY = 'kubelogin'
15+
MOCK_KUBELOGIN_PATH = '/usr/bin/kubelogin'
16+
KUBELOGIN_TIMEOUT = 60
17+
KUBELOGIN_CMD_PREFIX = [KUBELOGIN_BINARY, "convert-kubeconfig", "-l", "azurecli", "--kubeconfig"]
18+
KUBELOGIN_GITHUB_URL = "https://github.com/Azure/kubelogin"
19+
KUBECONFIG_SUFFIX = '.kubeconfig'
20+
STDOUT_PATH = '-'
21+
CUSTOM_CONFIG_PATH = '/custom/path/to/config'
22+
23+
24+
class TestKubeloginConversion(unittest.TestCase):
25+
"""Test cases for kubelogin automatic conversion functionality."""
26+
27+
@patch('azext_fleet.custom.shutil.which')
28+
@patch('azext_fleet.custom.subprocess.run')
29+
@patch('azext_fleet.custom.logger')
30+
def test_convert_with_kubelogin_available(self, mock_logger, mock_subprocess, mock_which):
31+
"""Test conversion when kubelogin is available."""
32+
mock_which.return_value = MOCK_KUBELOGIN_PATH
33+
mock_subprocess.return_value = MagicMock()
34+
35+
with tempfile.NamedTemporaryFile(mode='w', suffix=KUBECONFIG_SUFFIX, delete=False) as f:
36+
test_path = f.name
37+
38+
try:
39+
_convert_kubeconfig_to_azurecli(test_path)
40+
41+
# Verify kubelogin was called with correct arguments
42+
mock_subprocess.assert_called_once()
43+
call_args = mock_subprocess.call_args
44+
self.assertEqual(call_args[0][0], KUBELOGIN_CMD_PREFIX + [test_path])
45+
self.assertTrue(call_args[1]['check'])
46+
self.assertEqual(call_args[1]['timeout'], KUBELOGIN_TIMEOUT)
47+
48+
# Verify success message was logged
49+
mock_logger.warning.assert_called_with("Converted kubeconfig to use Azure CLI authentication.")
50+
finally:
51+
if os.path.exists(test_path):
52+
os.remove(test_path)
53+
54+
@patch('azext_fleet.custom.shutil.which')
55+
@patch('azext_fleet.custom.logger')
56+
def test_convert_with_kubelogin_unavailable(self, mock_logger, mock_which):
57+
"""Test conversion when kubelogin is not available."""
58+
mock_which.return_value = None
59+
60+
with tempfile.NamedTemporaryFile(mode='w', suffix=KUBECONFIG_SUFFIX, delete=False) as f:
61+
test_path = f.name
62+
63+
try:
64+
_convert_kubeconfig_to_azurecli(test_path)
65+
66+
# Verify warning message was logged
67+
mock_logger.warning.assert_called_once()
68+
warning_msg = mock_logger.warning.call_args[0][0]
69+
self.assertIn("kubeconfig requires kubelogin", warning_msg)
70+
self.assertIn(KUBELOGIN_GITHUB_URL, warning_msg)
71+
finally:
72+
if os.path.exists(test_path):
73+
os.remove(test_path)
74+
75+
@patch('azext_fleet.custom.shutil.which')
76+
@patch('azext_fleet.custom.subprocess.run')
77+
@patch('azext_fleet.custom.logger')
78+
def test_convert_handles_subprocess_error(self, mock_logger, mock_subprocess, mock_which):
79+
"""Test that subprocess errors are handled gracefully."""
80+
mock_which.return_value = MOCK_KUBELOGIN_PATH
81+
from subprocess import CalledProcessError
82+
mock_subprocess.side_effect = CalledProcessError(1, KUBELOGIN_BINARY)
83+
84+
with tempfile.NamedTemporaryFile(mode='w', suffix=KUBECONFIG_SUFFIX, delete=False) as f:
85+
test_path = f.name
86+
87+
try:
88+
_convert_kubeconfig_to_azurecli(test_path)
89+
90+
# Verify error was logged
91+
mock_logger.warning.assert_called_once()
92+
warning_msg = mock_logger.warning.call_args[0][0]
93+
self.assertIn("Failed to convert kubeconfig with kubelogin", warning_msg)
94+
finally:
95+
if os.path.exists(test_path):
96+
os.remove(test_path)
97+
98+
@patch('azext_fleet.custom.shutil.which')
99+
@patch('azext_fleet.custom.subprocess.run')
100+
@patch('azext_fleet.custom.logger')
101+
def test_convert_handles_timeout(self, mock_logger, mock_subprocess, mock_which):
102+
"""Test that timeout errors are handled gracefully."""
103+
mock_which.return_value = MOCK_KUBELOGIN_PATH
104+
from subprocess import TimeoutExpired
105+
mock_subprocess.side_effect = TimeoutExpired(KUBELOGIN_BINARY, KUBELOGIN_TIMEOUT)
106+
107+
with tempfile.NamedTemporaryFile(mode='w', suffix=KUBECONFIG_SUFFIX, delete=False) as f:
108+
test_path = f.name
109+
110+
try:
111+
_convert_kubeconfig_to_azurecli(test_path)
112+
113+
# Verify timeout was logged
114+
mock_logger.warning.assert_called_once()
115+
warning_msg = mock_logger.warning.call_args[0][0]
116+
self.assertIn("kubelogin command timed out", warning_msg)
117+
finally:
118+
if os.path.exists(test_path):
119+
os.remove(test_path)
120+
121+
@patch('azext_fleet.custom.shutil.which')
122+
@patch('azext_fleet.custom.subprocess.run')
123+
@patch('azext_fleet.custom.logger')
124+
def test_convert_handles_generic_exception(self, mock_logger, mock_subprocess, mock_which):
125+
"""Test that generic exceptions are handled gracefully."""
126+
mock_which.return_value = MOCK_KUBELOGIN_PATH
127+
mock_subprocess.side_effect = Exception("Unexpected error")
128+
129+
with tempfile.NamedTemporaryFile(mode='w', suffix=KUBECONFIG_SUFFIX, delete=False) as f:
130+
test_path = f.name
131+
132+
try:
133+
_convert_kubeconfig_to_azurecli(test_path)
134+
135+
# Verify error was logged
136+
mock_logger.warning.assert_called_once()
137+
warning_msg = mock_logger.warning.call_args[0][0]
138+
self.assertIn("Error running kubelogin", warning_msg)
139+
finally:
140+
if os.path.exists(test_path):
141+
os.remove(test_path)
142+
143+
@patch('azext_fleet.custom.shutil.which')
144+
@patch('azext_fleet.custom.subprocess.run')
145+
def test_convert_skips_stdout_path(self, mock_subprocess, mock_which):
146+
"""Test that conversion is skipped when path is stdout."""
147+
mock_which.return_value = MOCK_KUBELOGIN_PATH
148+
149+
_convert_kubeconfig_to_azurecli(STDOUT_PATH)
150+
151+
# Verify subprocess was not called
152+
mock_subprocess.assert_not_called()
153+
154+
@patch('azext_fleet.custom.shutil.which')
155+
@patch('azext_fleet.custom.subprocess.run')
156+
def test_convert_with_custom_path(self, mock_subprocess, mock_which):
157+
"""Test conversion with custom kubeconfig path."""
158+
mock_which.return_value = MOCK_KUBELOGIN_PATH
159+
mock_subprocess.return_value = MagicMock()
160+
161+
_convert_kubeconfig_to_azurecli(CUSTOM_CONFIG_PATH)
162+
163+
# Verify kubelogin was called with custom path
164+
call_args = mock_subprocess.call_args
165+
self.assertEqual(call_args[0][0], KUBELOGIN_CMD_PREFIX + [CUSTOM_CONFIG_PATH])
166+
167+
168+
if __name__ == '__main__':
169+
unittest.main()

src/fleet/setup.py

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

1717
# TODO: Confirm this is the right version number you want and it matches your
1818
# HISTORY.rst entry.
19-
VERSION = '1.8.2'
19+
VERSION = '1.8.3'
2020

2121
# The full list of classifiers is available at
2222
# https://pypi.python.org/pypi?%3Aaction=list_classifiers

0 commit comments

Comments
 (0)