Skip to content

Commit 512728d

Browse files
authored
feat: add generate_kubeconfig support to get_client (#2679)
* feat: add kubeconfig_output_path parameter to get_client * fix: address review - config_file error handling, docstring, write failure test * fix: address CodeRabbit review - atomic write, yaml error handling, token resolution helper * fix: address review - raise on errors, S106 fixes, atomic write mock * fix: update docstring, use shutil.copy2 for config_file path * fix: remove config_file from save_kubeconfig - redundant when file already exists * Refactor kubeconfig_output_path to generate_kubeconfig boolean - Replaced kubeconfig_output_path: str with generate_kubeconfig: bool in get_client() - save_kubeconfig() now writes to a temp file and returns the path - Temp file is auto-cleaned via atexit on process exit - When generate_kubeconfig=True, get_client() returns (DynamicClient, str) tuple - Added @overload signatures for proper mypy type narrowing * Remove @overload signatures from get_client * Skip kubeconfig generation when config_file is available * Move kubeconfig helpers to client_config module - Renamed ocp_resources/utils/kubeconfig.py to client_config.py - Moved DynamicClientWithKubeconfig and resolve_bearer_token from resource.py - Updated all imports in resource.py and tests * Fix race condition in save_kubeconfig file creation - Replaced NamedTemporaryFile + os.chmod with mkstemp + os.fchmod - Permissions set atomically before content is written
1 parent ec8abc5 commit 512728d

3 files changed

Lines changed: 234 additions & 2 deletions

File tree

ocp_resources/resource.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
ResourceTeardownError,
4949
ValidationError,
5050
)
51+
from ocp_resources.utils.client_config import DynamicClientWithKubeconfig, resolve_bearer_token, save_kubeconfig
5152
from ocp_resources.utils.constants import (
5253
DEFAULT_CLUSTER_RETRY_EXCEPTIONS,
5354
NOT_FOUND_ERROR_EXCEPTION_DICT,
@@ -206,6 +207,7 @@ def get_client(
206207
verify_ssl: bool | None = None,
207208
token: str | None = None,
208209
fake: bool = False,
210+
generate_kubeconfig: bool = False,
209211
) -> DynamicClient | FakeDynamicClient:
210212
"""
211213
Get a kubernetes client.
@@ -230,6 +232,7 @@ def get_client(
230232
host (str): host for the cluster
231233
verify_ssl (bool): whether to verify ssl
232234
token (str): Use token to login
235+
generate_kubeconfig (bool): if True, save the kubeconfig to a temporary file and add path to client kubeconfig attribute.
233236
234237
Returns:
235238
DynamicClient: a kubernetes client.
@@ -286,16 +289,32 @@ def get_client(
286289
kubernetes.client.Configuration.set_default(default=client_configuration)
287290

288291
try:
289-
return kubernetes.dynamic.DynamicClient(client=_client)
292+
_dynamic_client = kubernetes.dynamic.DynamicClient(client=_client)
290293
except MaxRetryError:
291294
# Ref: https://github.com/kubernetes-client/python/blob/v26.1.0/kubernetes/base/config/incluster_config.py
292295
LOGGER.info("Trying to get client via incluster_config")
293-
return kubernetes.dynamic.DynamicClient(
296+
_dynamic_client = kubernetes.dynamic.DynamicClient(
294297
client=kubernetes.config.incluster_config.load_incluster_config(
295298
client_configuration=client_configuration, try_refresh_token=try_refresh_token
296299
),
297300
)
298301

302+
if generate_kubeconfig:
303+
if config_file:
304+
LOGGER.info(f"`generate_kubeconfig` ignored, kubeconfig already available at {config_file}")
305+
_dynamic_client = DynamicClientWithKubeconfig(client=_dynamic_client.client, kubeconfig=config_file)
306+
else:
307+
_resolved_token = resolve_bearer_token(token=token, client_configuration=client_configuration)
308+
kubeconfig_path = save_kubeconfig(
309+
host=host or client_configuration.host,
310+
token=_resolved_token,
311+
config_dict=config_dict,
312+
verify_ssl=verify_ssl,
313+
)
314+
_dynamic_client = DynamicClientWithKubeconfig(client=_dynamic_client.client, kubeconfig=kubeconfig_path)
315+
316+
return _dynamic_client
317+
299318

300319
def sub_resource_level(current_class: Any, owner_class: Any, parent_class: Any) -> str | None:
301320
# return the name of the last class in MRO list that is not one of base
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import atexit
2+
import os
3+
import tempfile
4+
from typing import Any
5+
6+
import kubernetes
7+
import yaml
8+
from kubernetes.dynamic import DynamicClient
9+
from simple_logger.logger import get_logger
10+
11+
LOGGER = get_logger(name=__name__)
12+
13+
14+
class DynamicClientWithKubeconfig(DynamicClient):
15+
def __init__(self, client: kubernetes.client.ApiClient, kubeconfig: str) -> None:
16+
super().__init__(client=client)
17+
self.kubeconfig = kubeconfig
18+
19+
20+
def resolve_bearer_token(
21+
token: str | None,
22+
client_configuration: "kubernetes.client.Configuration",
23+
) -> str | None:
24+
"""Extract bearer token from client configuration if not explicitly provided."""
25+
if token:
26+
return token
27+
28+
if client_configuration.api_key:
29+
_bearer = client_configuration.api_key.get("authorization", "")
30+
if _bearer.startswith("Bearer "):
31+
return _bearer.removeprefix("Bearer ")
32+
33+
return None
34+
35+
36+
def save_kubeconfig(
37+
host: str | None = None,
38+
token: str | None = None,
39+
config_dict: dict[str, Any] | None = None,
40+
verify_ssl: bool | None = None,
41+
) -> str:
42+
"""
43+
Save kubeconfig to a temporary file.
44+
45+
Builds a kubeconfig from the provided parameters and writes it to a
46+
temporary file with 0o600 permissions.
47+
48+
Args:
49+
host (str): cluster API server URL.
50+
token (str): bearer token for authentication.
51+
config_dict (dict): existing kubeconfig dict to save as-is.
52+
verify_ssl (bool): if False, sets insecure-skip-tls-verify in the saved config.
53+
54+
Returns:
55+
str: path to the temporary kubeconfig file.
56+
"""
57+
if config_dict is not None:
58+
_config = config_dict
59+
elif host:
60+
cluster_config: dict[str, Any] = {"server": host}
61+
if verify_ssl is False:
62+
cluster_config["insecure-skip-tls-verify"] = True
63+
64+
user_config: dict[str, str] = {}
65+
if token:
66+
user_config["token"] = token
67+
68+
_config = {
69+
"apiVersion": "v1",
70+
"kind": "Config",
71+
"clusters": [{"name": "cluster", "cluster": cluster_config}],
72+
"users": [{"name": "user", "user": user_config}],
73+
"contexts": [{"name": "context", "context": {"cluster": "cluster", "user": "user"}}],
74+
"current-context": "context",
75+
}
76+
else:
77+
raise ValueError("Not enough data to build kubeconfig: provide config_dict or host")
78+
79+
fd = None
80+
tmp_path = None
81+
try:
82+
fd, tmp_path = tempfile.mkstemp(suffix=".kubeconfig")
83+
os.fchmod(fd, 0o600)
84+
with os.fdopen(fd, "w") as f:
85+
fd = None
86+
yaml.safe_dump(_config, f)
87+
atexit.register(lambda p: os.unlink(p) if os.path.exists(p) else None, tmp_path)
88+
LOGGER.info(f"kubeconfig saved to {tmp_path}")
89+
return tmp_path
90+
91+
except (OSError, yaml.YAMLError):
92+
if fd is not None:
93+
os.close(fd)
94+
if tmp_path is not None and os.path.exists(tmp_path):
95+
os.unlink(tmp_path)
96+
LOGGER.error("Failed to save kubeconfig")
97+
raise

tests/test_resource.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import os
22
from unittest.mock import patch
33

4+
import kubernetes
45
import pytest
6+
import yaml
57

68
from ocp_resources.exceptions import ResourceTeardownError
79
from ocp_resources.namespace import Namespace
810
from ocp_resources.pod import Pod
911
from ocp_resources.resource import NamespacedResourceList, Resource, ResourceList
1012
from ocp_resources.secret import Secret
13+
from ocp_resources.utils.client_config import resolve_bearer_token, save_kubeconfig
1114

1215
BASE_NAMESPACE_NAME: str = "test-namespace"
1316
BASE_POD_NAME: str = "test-pod"
@@ -200,3 +203,116 @@ def test_proxy_precedence(self, fake_client):
200203

201204
# Verify HTTPS_PROXY takes precedence over HTTP_PROXY
202205
assert fake_client.configuration.proxy == https_proxy
206+
207+
208+
class TestSaveKubeconfig:
209+
def test_save_kubeconfig_with_host_and_token(self):
210+
host = "https://api.test-cluster.example.com:6443"
211+
token = "sha256~test-token-value" # noqa: S105
212+
213+
kubeconfig_path = save_kubeconfig(host=host, token=token, verify_ssl=False)
214+
try:
215+
assert os.path.exists(kubeconfig_path)
216+
assert os.stat(kubeconfig_path).st_mode & 0o777 == 0o600
217+
218+
with open(kubeconfig_path) as f:
219+
config = yaml.safe_load(f)
220+
221+
assert config["clusters"][0]["cluster"]["server"] == host
222+
assert config["clusters"][0]["cluster"]["insecure-skip-tls-verify"] is True
223+
assert config["users"][0]["user"]["token"] == token
224+
assert config["current-context"] == "context"
225+
finally:
226+
os.unlink(kubeconfig_path)
227+
228+
def test_save_kubeconfig_with_config_dict(self):
229+
config_dict = {
230+
"apiVersion": "v1",
231+
"kind": "Config",
232+
"clusters": [{"name": "my-cluster", "cluster": {"server": "https://my-server:6443"}}],
233+
"users": [{"name": "my-user", "user": {"token": "my-token"}}],
234+
"contexts": [{"name": "my-context", "context": {"cluster": "my-cluster", "user": "my-user"}}],
235+
"current-context": "my-context",
236+
}
237+
238+
kubeconfig_path = save_kubeconfig(config_dict=config_dict)
239+
try:
240+
with open(kubeconfig_path) as f:
241+
saved_config = yaml.safe_load(f)
242+
243+
assert saved_config == config_dict
244+
finally:
245+
os.unlink(kubeconfig_path)
246+
247+
def test_save_kubeconfig_insufficient_data(self):
248+
with pytest.raises(ValueError, match="Not enough data to build kubeconfig"):
249+
save_kubeconfig()
250+
251+
def test_save_kubeconfig_file_permissions(self):
252+
_test_token = "test-token" # noqa: S105
253+
254+
kubeconfig_path = save_kubeconfig(host="https://api.example.com:6443", token=_test_token)
255+
try:
256+
assert os.stat(kubeconfig_path).st_mode & 0o777 == 0o600
257+
finally:
258+
os.unlink(kubeconfig_path)
259+
260+
def test_save_kubeconfig_verify_ssl_not_false(self):
261+
_test_token = "test-token" # noqa: S105
262+
263+
kubeconfig_path_true = save_kubeconfig(host="https://api.example.com:6443", token=_test_token, verify_ssl=True)
264+
try:
265+
with open(kubeconfig_path_true) as f:
266+
config_true = yaml.safe_load(f)
267+
268+
assert "insecure-skip-tls-verify" not in config_true["clusters"][0]["cluster"]
269+
finally:
270+
os.unlink(kubeconfig_path_true)
271+
272+
kubeconfig_path_none = save_kubeconfig(host="https://api.example.com:6443", token=_test_token, verify_ssl=None)
273+
try:
274+
with open(kubeconfig_path_none) as f:
275+
config_none = yaml.safe_load(f)
276+
finally:
277+
os.unlink(kubeconfig_path_none)
278+
279+
assert "insecure-skip-tls-verify" not in config_none["clusters"][0]["cluster"]
280+
281+
def testresolve_bearer_token_from_api_key(self):
282+
"""Test that resolve_bearer_token extracts token from Bearer api_key."""
283+
cfg = kubernetes.client.Configuration()
284+
cfg.api_key = {"authorization": "Bearer sha256~oauth-resolved-token"} # noqa: S105
285+
result = resolve_bearer_token(token=None, client_configuration=cfg)
286+
assert result == "sha256~oauth-resolved-token"
287+
288+
def testresolve_bearer_token_explicit_takes_precedence(self):
289+
"""Test that an explicit token takes precedence over Bearer in api_key."""
290+
cfg = kubernetes.client.Configuration()
291+
cfg.api_key = {"authorization": "Bearer sha256~oauth-token"} # noqa: S105
292+
explicit_token = "explicit-token" # noqa: S105
293+
result = resolve_bearer_token(token=explicit_token, client_configuration=cfg)
294+
assert result == "explicit-token"
295+
296+
def testresolve_bearer_token_no_bearer_prefix(self):
297+
"""Test that api_key without Bearer prefix does not resolve a token."""
298+
cfg = kubernetes.client.Configuration()
299+
cfg.api_key = {"authorization": "Basic some-basic-auth"}
300+
result = resolve_bearer_token(token=None, client_configuration=cfg)
301+
assert result is None
302+
303+
def testresolve_bearer_token_empty_api_key(self):
304+
"""Test that empty api_key does not resolve a token."""
305+
cfg = kubernetes.client.Configuration()
306+
cfg.api_key = {}
307+
result = resolve_bearer_token(token=None, client_configuration=cfg)
308+
assert result is None
309+
310+
def test_save_kubeconfig_write_failure(self):
311+
_test_token = "test-token" # noqa: S105
312+
313+
with pytest.raises(OSError, match="Permission denied"):
314+
with patch(
315+
"ocp_resources.utils.client_config.tempfile.mkstemp",
316+
side_effect=OSError("Permission denied"),
317+
):
318+
save_kubeconfig(host="https://api.example.com:6443", token=_test_token)

0 commit comments

Comments
 (0)