Skip to content

Commit 34f55f0

Browse files
committed
Make resource lookup case-insensitive to match Kubernetes API behavior
1 parent d80165d commit 34f55f0

File tree

2 files changed

+244
-1
lines changed

2 files changed

+244
-1
lines changed

kubernetes/base/dynamic/discovery.py

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,27 @@ def get_resources_for_api_version(self, prefix, group, version, preferred):
195195
resources[resource_list.kind].append(resource_list)
196196
return resources
197197

198+
def _find_resource_case_insensitive(self, kind, resources_dict):
199+
"""
200+
Find a resource in the resources_dict where the key matches the specified kind,
201+
regardless of case.
202+
203+
Args:
204+
kind: The kind to search for (case-insensitive)
205+
resources_dict: The resources dictionary to search in
206+
207+
Returns:
208+
The actual key if found, None otherwise
209+
"""
210+
if not kind:
211+
return None
212+
213+
kind_lower = kind.lower()
214+
for key in resources_dict.keys():
215+
if key.lower() == kind_lower:
216+
return key
217+
return None
218+
198219
def get(self, **kwargs):
199220
""" Same as search, but will throw an error if there are multiple or no
200221
results. If there are multiple results and only one is an exact match
@@ -241,14 +262,69 @@ def api_groups(self):
241262
return self.parse_api_groups(request_resources=False, update=True)['apis'].keys()
242263

243264
def search(self, **kwargs):
265+
# Save the original kind parameter for case-insensitive lookup if needed
266+
original_kind = kwargs.get('kind')
267+
244268
# In first call, ignore ResourceNotFoundError and set default value for results
245269
try:
246270
results = self.__search(self.__build_search(**kwargs), self.__resources, [])
247271
except ResourceNotFoundError:
248272
results = []
273+
274+
# If no results were found and a kind was specified, try case-insensitive lookup
275+
if not results and original_kind and kwargs.get('kind') == original_kind:
276+
# Iterate through the resource tree to find a case-insensitive match
277+
for prefix, groups in self.__resources.items():
278+
for group, versions in groups.items():
279+
for version, rg in versions.items():
280+
if hasattr(rg, "resources") and rg.resources:
281+
# Look for a matching kind (case-insensitive)
282+
matching_kind = self._find_resource_case_insensitive(original_kind, rg.resources)
283+
if matching_kind:
284+
# Try again with the correct case
285+
modified_kwargs = kwargs.copy()
286+
modified_kwargs['kind'] = matching_kind
287+
try:
288+
results = self.__search(self.__build_search(**modified_kwargs), self.__resources, [])
289+
if results:
290+
break
291+
except ResourceNotFoundError:
292+
continue
293+
294+
# If still no results, invalidate cache and retry
249295
if not results:
250296
self.invalidate_cache()
251-
results = self.__search(self.__build_search(**kwargs), self.__resources, [])
297+
298+
# Reset kind parameter that might have been modified
299+
if original_kind:
300+
kwargs['kind'] = original_kind
301+
302+
# Try exact match first
303+
try:
304+
results = self.__search(self.__build_search(**kwargs), self.__resources, [])
305+
except ResourceNotFoundError:
306+
# If exact match fails, try case-insensitive lookup
307+
if original_kind:
308+
# Same case-insensitive lookup logic as above
309+
for prefix, groups in self.__resources.items():
310+
for group, versions in groups.items():
311+
for version, rg in versions.items():
312+
if hasattr(rg, "resources") and rg.resources:
313+
matching_kind = self._find_resource_case_insensitive(original_kind, rg.resources)
314+
if matching_kind:
315+
modified_kwargs = kwargs.copy()
316+
modified_kwargs['kind'] = matching_kind
317+
try:
318+
results = self.__search(self.__build_search(**modified_kwargs), self.__resources, [])
319+
if results:
320+
break
321+
except ResourceNotFoundError:
322+
continue
323+
324+
# If still no results, set empty list
325+
if not results:
326+
results = []
327+
252328
self.__maybe_write_cache()
253329
return results
254330

@@ -349,10 +425,54 @@ def search(self, **kwargs):
349425
350426
The arbitrary arguments can be any valid attribute for an Resource object
351427
"""
428+
# Save original kind parameter for case-insensitive lookup if needed
429+
original_kind = kwargs.get('kind')
430+
431+
# Try original search first
352432
results = self.__search(self.__build_search(**kwargs), self.__resources)
433+
434+
# If no results were found and a kind was specified, try case-insensitive lookup
435+
if not results and original_kind and kwargs.get('kind') == original_kind:
436+
# Iterate through the resource tree to find a case-insensitive match
437+
for prefix, groups in self.__resources.items():
438+
for group, versions in groups.items():
439+
for version, resource_dict in versions.items():
440+
if isinstance(resource_dict, ResourceGroup) and resource_dict.resources:
441+
# Look for a matching kind (case-insensitive)
442+
matching_kind = self._find_resource_case_insensitive(original_kind, resource_dict.resources)
443+
if matching_kind:
444+
# Try again with the correct case
445+
modified_kwargs = kwargs.copy()
446+
modified_kwargs['kind'] = matching_kind
447+
results = self.__search(self.__build_search(**modified_kwargs), self.__resources)
448+
if results:
449+
break
450+
451+
# If still no results, invalidate cache and retry
353452
if not results:
354453
self.invalidate_cache()
454+
455+
# Reset kind parameter that might have been modified
456+
if original_kind:
457+
kwargs['kind'] = original_kind
458+
459+
# Try exact match first
355460
results = self.__search(self.__build_search(**kwargs), self.__resources)
461+
462+
# If exact match fails, try case-insensitive lookup
463+
if not results and original_kind:
464+
for prefix, groups in self.__resources.items():
465+
for group, versions in groups.items():
466+
for version, resource_dict in versions.items():
467+
if isinstance(resource_dict, ResourceGroup) and resource_dict.resources:
468+
matching_kind = self._find_resource_case_insensitive(original_kind, resource_dict.resources)
469+
if matching_kind:
470+
modified_kwargs = kwargs.copy()
471+
modified_kwargs['kind'] = matching_kind
472+
results = self.__search(self.__build_search(**modified_kwargs), self.__resources)
473+
if results:
474+
break
475+
356476
return results
357477

358478
def __build_search(self, prefix=None, group=None, api_version=None, kind=None, **kwargs):
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# Copyright 2023 The Kubernetes Authors.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""
16+
Test for case insensitive resource lookup in the Dynamic Client.
17+
This test addresses issue #2402: Resource lookup is case-sensitive while it shouldn't
18+
"""
19+
20+
import unittest
21+
import kubernetes.config as config
22+
from kubernetes import client, dynamic
23+
from kubernetes.dynamic.exceptions import ResourceNotFoundError
24+
import os
25+
import sys
26+
import logging
27+
28+
# Configure logging
29+
logging.basicConfig(level=logging.INFO)
30+
logger = logging.getLogger(__name__)
31+
32+
33+
class TestCaseInsensitiveDiscovery(unittest.TestCase):
34+
"""
35+
Test case for case-insensitive resource lookup in the Dynamic Client.
36+
"""
37+
38+
@classmethod
39+
def setUpClass(cls):
40+
"""
41+
Set up test class - load kubernetes configuration
42+
"""
43+
try:
44+
config.load_kube_config()
45+
cls.api_client = client.ApiClient()
46+
cls.dynamic_client = dynamic.DynamicClient(cls.api_client)
47+
except Exception as e:
48+
logger.warning(f"Could not load kubernetes configuration: {e}")
49+
cls.skipTest(cls, f"Failed to load kubernetes configuration: {e}")
50+
51+
def test_case_sensitivity_service(self):
52+
"""
53+
Test that Service resource can be found regardless of case
54+
"""
55+
# 1. Test with exact case
56+
try:
57+
resource = self.dynamic_client.resources.get(kind='Service')
58+
self.assertEqual(resource.kind, 'Service')
59+
logger.info("Successfully found resource with correct case: Service")
60+
except Exception as e:
61+
self.fail(f"Failed to get resource with correct case 'Service': {e}")
62+
63+
# 2. Test with lowercase
64+
try:
65+
resource = self.dynamic_client.resources.get(kind='service')
66+
self.assertEqual(resource.kind, 'Service')
67+
logger.info("Successfully found resource with lowercase: service")
68+
except Exception as e:
69+
self.fail(f"Failed to get resource with lowercase 'service': {e}")
70+
71+
# 3. Test with mixed case
72+
try:
73+
resource = self.dynamic_client.resources.get(kind='SerVicE')
74+
self.assertEqual(resource.kind, 'Service')
75+
logger.info("Successfully found resource with mixed case: SerVicE")
76+
except Exception as e:
77+
self.fail(f"Failed to get resource with mixed case 'SerVicE': {e}")
78+
79+
def test_case_sensitivity_deployment(self):
80+
"""
81+
Test that Deployment resource can be found regardless of case
82+
"""
83+
# 1. Test with exact case
84+
try:
85+
resource = self.dynamic_client.resources.get(kind='Deployment')
86+
self.assertEqual(resource.kind, 'Deployment')
87+
logger.info("Successfully found resource with correct case: Deployment")
88+
except Exception as e:
89+
self.fail(f"Failed to get resource with correct case 'Deployment': {e}")
90+
91+
# 2. Test with lowercase
92+
try:
93+
resource = self.dynamic_client.resources.get(kind='deployment')
94+
self.assertEqual(resource.kind, 'Deployment')
95+
logger.info("Successfully found resource with lowercase: deployment")
96+
except Exception as e:
97+
self.fail(f"Failed to get resource with lowercase 'deployment': {e}")
98+
99+
def test_nonexistent_resource(self):
100+
"""
101+
Test that looking up a non-existent resource still returns the appropriate error
102+
"""
103+
with self.assertRaises(ResourceNotFoundError):
104+
self.dynamic_client.resources.get(kind='NonExistentResource')
105+
logger.info("Correctly raised ResourceNotFoundError for non-existent resource")
106+
107+
def test_with_api_version(self):
108+
"""
109+
Test case insensitive lookup with api_version specified
110+
"""
111+
try:
112+
resource = self.dynamic_client.resources.get(
113+
api_version='apps/v1', kind='deployment')
114+
self.assertEqual(resource.kind, 'Deployment')
115+
self.assertEqual(resource.group, 'apps')
116+
self.assertEqual(resource.api_version, 'v1')
117+
logger.info("Successfully found resource with api_version and lowercase kind")
118+
except Exception as e:
119+
self.fail(f"Failed to get resource with api_version and lowercase kind: {e}")
120+
121+
122+
if __name__ == '__main__':
123+
unittest.main()

0 commit comments

Comments
 (0)