Skip to content

Commit b7bab3b

Browse files
committed
added metrics tests
1 parent 291e1e4 commit b7bab3b

File tree

4 files changed

+434
-32
lines changed

4 files changed

+434
-32
lines changed

robusta_krr/core/integrations/kubernetes/__init__.py

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@
2323
from robusta_krr.core.models.result import ResourceAllocations
2424
from robusta_krr.utils.object_like_dict import ObjectLikeDict
2525

26+
27+
class LightweightJobInfo:
28+
"""Lightweight job object containing only the fields needed for GroupedJob processing."""
29+
def __init__(self, name: str, namespace: str):
30+
self.name = name
31+
self.namespace = namespace
32+
33+
2634
from . import config_patch as _
2735

2836
logger = logging.getLogger("krr")
@@ -606,7 +614,9 @@ async def _list_all_groupedjobs(self) -> list[K8sObjectData]:
606614
logger.debug("Listing GroupedJobs with grouping labels: %s", settings.job_grouping_labels)
607615

608616
# Get all jobs that have any of the grouping labels using batched loading
617+
609618
grouped_jobs = defaultdict(list)
619+
grouped_jobs_template = {} # Store only ONE full job as template per group
610620
continue_ref: Optional[str] = None
611621
batch_count = 0
612622

@@ -629,8 +639,15 @@ async def _list_all_groupedjobs(self) -> list[K8sObjectData]:
629639
if label_name in job.metadata.labels:
630640
label_value = job.metadata.labels[label_name]
631641
group_key = f"{label_name}={label_value}"
632-
grouped_jobs[group_key].append(job)
633-
break # Only add to first matching group
642+
# Store lightweight job info only
643+
lightweight_job = LightweightJobInfo(
644+
name=job.metadata.name,
645+
namespace=job.metadata.namespace
646+
)
647+
grouped_jobs[group_key].append(lightweight_job)
648+
# Keep only ONE full job as template per group
649+
if group_key not in grouped_jobs_template:
650+
grouped_jobs_template[group_key] = job
634651

635652
batch_count += 1
636653

@@ -646,21 +663,23 @@ async def _list_all_groupedjobs(self) -> list[K8sObjectData]:
646663
raise e
647664

648665
result = []
649-
for group_name, jobs in grouped_jobs.items():
650-
jobs_by_namespace = defaultdict(list)
651-
for job in jobs:
652-
jobs_by_namespace[job.metadata.namespace].append(job)
666+
for group_name, lightweight_jobs in grouped_jobs.items():
667+
# Get the one template job for this group
668+
template_job = grouped_jobs_template[group_name]
669+
670+
# Group lightweight jobs by namespace
671+
lightweight_jobs_by_namespace = defaultdict(list)
672+
for lightweight_job in lightweight_jobs:
673+
lightweight_jobs_by_namespace[lightweight_job.namespace].append(lightweight_job)
653674

654-
for namespace, namespace_jobs in jobs_by_namespace.items():
655-
limited_jobs = namespace_jobs[:settings.job_grouping_limit]
675+
for namespace, namespace_lightweight_jobs in lightweight_jobs_by_namespace.items():
676+
limited_lightweight_jobs = namespace_lightweight_jobs[:settings.job_grouping_limit]
656677

657678
container_names = set()
658-
for job in limited_jobs:
659-
for container in job.spec.template.spec.containers:
660-
container_names.add(container.name)
679+
for container in template_job.spec.template.spec.containers:
680+
container_names.add(container.name)
661681

662682
for container_name in container_names:
663-
template_job = limited_jobs[0]
664683
template_container = None
665684
for container in template_job.spec.template.spec.containers:
666685
if container.name == container_name:
@@ -676,7 +695,8 @@ async def _list_all_groupedjobs(self) -> list[K8sObjectData]:
676695

677696
grouped_job.name = group_name
678697
grouped_job.namespace = namespace
679-
grouped_job._api_resource._grouped_jobs = limited_jobs
698+
# Store only lightweight job info
699+
grouped_job._api_resource._grouped_jobs = limited_lightweight_jobs
680700
grouped_job._api_resource._label_filter = group_name
681701

682702
result.append(grouped_job)

robusta_krr/core/integrations/prometheus/metrics_service/prometheus_metrics_service.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,7 @@ async def load_pods(self, object: K8sObjectData, period: timedelta) -> list[PodD
351351
del jobs
352352
elif object.kind == "GroupedJob":
353353
if hasattr(object._api_resource, '_grouped_jobs'):
354-
pod_owners = [job.metadata.name for job in object._api_resource._grouped_jobs]
354+
pod_owners = [job.name for job in object._api_resource._grouped_jobs]
355355
pod_owner_kind = "Job"
356356
else:
357357
pod_owners = [object.name]

tests/test_grouped_jobs.py

Lines changed: 123 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,13 @@ def mock_config():
1010
config = MagicMock(spec=Config)
1111
config.job_grouping_labels = ["app", "team"]
1212
config.job_grouping_limit = 3 # Small limit for testing
13+
config.discovery_job_batch_size = 1000
14+
config.discovery_job_max_batches = 50
1315
config.max_workers = 4
1416
config.get_kube_client = MagicMock()
1517
config.resources = "*"
18+
config.selector = None
19+
config.namespaces = "*" # Add namespaces setting
1620
return config
1721

1822

@@ -23,7 +27,14 @@ def mock_kubernetes_loader(mock_config):
2327
loader = ClusterLoader()
2428
loader.batch = MagicMock()
2529
loader.core = MagicMock()
30+
31+
# Mock executor to return a proper Future
32+
from concurrent.futures import Future
33+
mock_future = Future()
34+
mock_future.set_result(None) # Set a dummy result
2635
loader.executor = MagicMock()
36+
loader.executor.submit.return_value = mock_future
37+
2738
loader._ClusterLoader__hpa_list = {} # type: ignore # needed for mock
2839
return loader
2940

@@ -60,16 +71,24 @@ async def test_list_all_groupedjobs_with_limit(mock_kubernetes_loader, mock_conf
6071
create_mock_job("job-9", "default", {"app": "backend"}), # This should be excluded
6172
]
6273

63-
# Mock the _list_namespaced_or_global_objects method
64-
mock_kubernetes_loader._list_namespaced_or_global_objects = AsyncMock(return_value=mock_jobs)
74+
# Mock the _list_namespaced_or_global_objects_batched method
75+
async def mock_batched_method(*args, **kwargs):
76+
# Create mock response objects that have the expected structure
77+
mock_response = MagicMock()
78+
mock_response.items = mock_jobs
79+
mock_response.metadata = MagicMock()
80+
mock_response.metadata._continue = None
81+
return (mock_jobs, None) # Return (jobs, continue_token)
82+
mock_kubernetes_loader._list_namespaced_or_global_objects_batched = mock_batched_method
6583

6684
# Mock the __build_scannable_object method
6785
def mock_build_scannable_object(item, container, kind):
6886
obj = MagicMock()
6987
obj._api_resource = MagicMock()
88+
obj.container = container.name
7089
return obj
7190

72-
mock_kubernetes_loader._KubernetesLoader__build_scannable_object = mock_build_scannable_object
91+
mock_kubernetes_loader._ClusterLoader__build_scannable_object = mock_build_scannable_object
7392

7493
# Patch the settings to use our mock config
7594
with patch("robusta_krr.core.integrations.kubernetes.settings", mock_config):
@@ -88,23 +107,23 @@ def mock_build_scannable_object(item, container, kind):
88107
assert frontend_objects[0].namespace == "default"
89108
assert frontend_objects[0].container == "main-container"
90109

91-
# Verify we got 1 backend object (one per unique container name)
110+
# Verify we got 1 backend object
92111
assert len(backend_objects) == 1
93112
assert backend_objects[0].namespace == "default"
94113
assert backend_objects[0].container == "main-container"
95114

96-
# Verify all objects in each group have the same grouped_jobs list
115+
# Verify all objects in each group have lightweight job info
97116
frontend_grouped_jobs = frontend_objects[0]._api_resource._grouped_jobs
98117
assert len(frontend_grouped_jobs) == 3
99-
assert frontend_grouped_jobs[0].metadata.name == "job-1"
100-
assert frontend_grouped_jobs[1].metadata.name == "job-2"
101-
assert frontend_grouped_jobs[2].metadata.name == "job-3"
118+
assert frontend_grouped_jobs[0].name == "job-1"
119+
assert frontend_grouped_jobs[1].name == "job-2"
120+
assert frontend_grouped_jobs[2].name == "job-3"
102121

103122
backend_grouped_jobs = backend_objects[0]._api_resource._grouped_jobs
104123
assert len(backend_grouped_jobs) == 3
105-
assert backend_grouped_jobs[0].metadata.name == "job-6"
106-
assert backend_grouped_jobs[1].metadata.name == "job-7"
107-
assert backend_grouped_jobs[2].metadata.name == "job-8"
124+
assert backend_grouped_jobs[0].name == "job-6"
125+
assert backend_grouped_jobs[1].name == "job-7"
126+
assert backend_grouped_jobs[2].name == "job-8"
108127

109128

110129
@pytest.mark.asyncio
@@ -119,14 +138,22 @@ async def test_list_all_groupedjobs_with_different_namespaces(mock_kubernetes_lo
119138
create_mock_job("job-4", "namespace-2", {"app": "frontend"}),
120139
]
121140

122-
mock_kubernetes_loader._list_namespaced_or_global_objects = AsyncMock(return_value=mock_jobs)
141+
async def mock_batched_method(*args, **kwargs):
142+
# Create mock response objects that have the expected structure
143+
mock_response = MagicMock()
144+
mock_response.items = mock_jobs
145+
mock_response.metadata = MagicMock()
146+
mock_response.metadata._continue = None
147+
return (mock_jobs, None) # Return (jobs, continue_token)
148+
mock_kubernetes_loader._list_namespaced_or_global_objects_batched = mock_batched_method
123149

124150
def mock_build_scannable_object(item, container, kind):
125151
obj = MagicMock()
126152
obj._api_resource = MagicMock()
153+
obj.container = container.name
127154
return obj
128155

129-
mock_kubernetes_loader._KubernetesLoader__build_scannable_object = mock_build_scannable_object
156+
mock_kubernetes_loader._ClusterLoader__build_scannable_object = mock_build_scannable_object
130157

131158
# Patch the settings to use our mock config
132159
with patch("robusta_krr.core.integrations.kubernetes.settings", mock_config):
@@ -166,14 +193,22 @@ async def test_list_all_groupedjobs_with_cronjob_owner_reference(mock_kubernetes
166193
# Add CronJob owner reference to the second job
167194
mock_jobs[1].metadata.owner_references = [MagicMock(kind="CronJob")]
168195

169-
mock_kubernetes_loader._list_namespaced_or_global_objects = AsyncMock(return_value=mock_jobs)
196+
async def mock_batched_method(*args, **kwargs):
197+
# Create mock response objects that have the expected structure
198+
mock_response = MagicMock()
199+
mock_response.items = mock_jobs
200+
mock_response.metadata = MagicMock()
201+
mock_response.metadata._continue = None
202+
return (mock_jobs, None) # Return (jobs, continue_token)
203+
mock_kubernetes_loader._list_namespaced_or_global_objects_batched = mock_batched_method
170204

171205
def mock_build_scannable_object(item, container, kind):
172206
obj = MagicMock()
173207
obj._api_resource = MagicMock()
208+
obj.container = container.name # Set the actual container name
174209
return obj
175210

176-
mock_kubernetes_loader._KubernetesLoader__build_scannable_object = mock_build_scannable_object
211+
mock_kubernetes_loader._ClusterLoader__build_scannable_object = mock_build_scannable_object
177212

178213
# Patch the settings to use our mock config
179214
with patch("robusta_krr.core.integrations.kubernetes.settings", mock_config):
@@ -185,7 +220,7 @@ def mock_build_scannable_object(item, container, kind):
185220
obj = result[0]
186221
assert obj.name == "app=frontend"
187222
assert len(obj._api_resource._grouped_jobs) == 1
188-
assert obj._api_resource._grouped_jobs[0].metadata.name == "job-1"
223+
assert obj._api_resource._grouped_jobs[0].name == "job-1"
189224

190225

191226
@pytest.mark.asyncio
@@ -212,14 +247,22 @@ async def test_list_all_groupedjobs_multiple_labels(mock_kubernetes_loader, mock
212247
create_mock_job("job-3", "default", {"app": "api"}),
213248
]
214249

215-
mock_kubernetes_loader._list_namespaced_or_global_objects = AsyncMock(return_value=mock_jobs)
250+
async def mock_batched_method(*args, **kwargs):
251+
# Create mock response objects that have the expected structure
252+
mock_response = MagicMock()
253+
mock_response.items = mock_jobs
254+
mock_response.metadata = MagicMock()
255+
mock_response.metadata._continue = None
256+
return (mock_jobs, None) # Return (jobs, continue_token)
257+
mock_kubernetes_loader._list_namespaced_or_global_objects_batched = mock_batched_method
216258

217259
def mock_build_scannable_object(item, container, kind):
218260
obj = MagicMock()
219261
obj._api_resource = MagicMock()
262+
obj.container = container.name
220263
return obj
221264

222-
mock_kubernetes_loader._KubernetesLoader__build_scannable_object = mock_build_scannable_object
265+
mock_kubernetes_loader._ClusterLoader__build_scannable_object = mock_build_scannable_object
223266

224267
# Patch the settings to use our mock config
225268
with patch("robusta_krr.core.integrations.kubernetes.settings", mock_config):
@@ -236,3 +279,65 @@ def mock_build_scannable_object(item, container, kind):
236279

237280
# Verify all objects have the same container name
238281
assert all(obj.container == "main-container" for obj in result)
282+
283+
284+
@pytest.mark.asyncio
285+
async def test_list_all_groupedjobs_job_in_multiple_groups(mock_kubernetes_loader, mock_config):
286+
"""Test that a job with multiple grouping labels is added to all matching groups"""
287+
288+
# Create a job that matches multiple grouping labels
289+
mock_jobs = [
290+
create_mock_job("job-1", "default", {"app": "frontend", "team": "web"}),
291+
create_mock_job("job-2", "default", {"app": "backend", "team": "api"}),
292+
]
293+
294+
async def mock_batched_method(*args, **kwargs):
295+
# Create mock response objects that have the expected structure
296+
mock_response = MagicMock()
297+
mock_response.items = mock_jobs
298+
mock_response.metadata = MagicMock()
299+
mock_response.metadata._continue = None
300+
return (mock_jobs, None) # Return (jobs, continue_token)
301+
mock_kubernetes_loader._list_namespaced_or_global_objects_batched = mock_batched_method
302+
303+
def mock_build_scannable_object(item, container, kind):
304+
obj = MagicMock()
305+
obj._api_resource = MagicMock()
306+
obj.container = container.name
307+
return obj
308+
309+
mock_kubernetes_loader._ClusterLoader__build_scannable_object = mock_build_scannable_object
310+
311+
# Patch the settings to use our mock config
312+
with patch("robusta_krr.core.integrations.kubernetes.settings", mock_config):
313+
# Call the method
314+
result = await mock_kubernetes_loader._list_all_groupedjobs()
315+
316+
# Verify we got 4 objects (2 jobs × 2 labels each = 4 groups)
317+
assert len(result) == 4
318+
319+
group_names = {g.name for g in result}
320+
assert "app=frontend" in group_names
321+
assert "app=backend" in group_names
322+
assert "team=web" in group_names
323+
assert "team=api" in group_names
324+
325+
# Find each group and verify it contains the correct job
326+
frontend_group = next(g for g in result if g.name == "app=frontend")
327+
backend_group = next(g for g in result if g.name == "app=backend")
328+
web_group = next(g for g in result if g.name == "team=web")
329+
api_group = next(g for g in result if g.name == "team=api")
330+
331+
# Verify job-1 is in both app=frontend and team=web groups
332+
assert len(frontend_group._api_resource._grouped_jobs) == 1
333+
assert frontend_group._api_resource._grouped_jobs[0].name == "job-1"
334+
335+
assert len(web_group._api_resource._grouped_jobs) == 1
336+
assert web_group._api_resource._grouped_jobs[0].name == "job-1"
337+
338+
# Verify job-2 is in both app=backend and team=api groups
339+
assert len(backend_group._api_resource._grouped_jobs) == 1
340+
assert backend_group._api_resource._grouped_jobs[0].name == "job-2"
341+
342+
assert len(api_group._api_resource._grouped_jobs) == 1
343+
assert api_group._api_resource._grouped_jobs[0].name == "job-2"

0 commit comments

Comments
 (0)