Skip to content

Commit 2d80a0e

Browse files
committed
Allow validation of Openshift allocations when changing quotas
As part of allowing the validation of allocation change requests (acr) [1], various functions have been added to the plugin that are mostly called from Coldfront to validate an acr. Among these changes are: - Several new functions to `utils.py` to perform basic tasks - Functions in the openshift allocator to obtain the usage of a Openshift project - Refactoring of how the Openshift quota value is parsed - New unit tests [1] nerc-project/coldfront-nerc#138
1 parent 5e2efea commit 2d80a0e

File tree

5 files changed

+204
-47
lines changed

5 files changed

+204
-47
lines changed

src/coldfront_plugin_cloud/management/commands/validate_allocations.py

Lines changed: 2 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import logging
2-
import re
32

43
from coldfront_plugin_cloud import attributes
54
from coldfront_plugin_cloud import openstack
65
from coldfront_plugin_cloud import openshift
76
from coldfront_plugin_cloud import utils
87
from coldfront_plugin_cloud import tasks
98

10-
from django.core.management.base import BaseCommand, CommandError
9+
from django.core.management.base import BaseCommand
1110
from coldfront.core.resource.models import Resource
1211
from coldfront.core.allocation.models import (
1312
Allocation,
@@ -242,51 +241,7 @@ def handle(self, *args, **options):
242241

243242
expected_value = allocation.get_attribute(attr)
244243
current_value = quota.get(key, None)
245-
246-
PATTERN = r"([0-9]+)(m|Ki|Mi|Gi|Ti|Pi|Ei|K|M|G|T|P|E)?"
247-
248-
suffix = {
249-
"Ki": 2**10,
250-
"Mi": 2**20,
251-
"Gi": 2**30,
252-
"Ti": 2**40,
253-
"Pi": 2**50,
254-
"Ei": 2**60,
255-
"m": 10**-3,
256-
"K": 10**3,
257-
"M": 10**6,
258-
"G": 10**9,
259-
"T": 10**12,
260-
"P": 10**15,
261-
"E": 10**18,
262-
}
263-
264-
if current_value and current_value != "0":
265-
result = re.search(PATTERN, current_value)
266-
267-
if result is None:
268-
raise CommandError(
269-
f"Unable to parse current_value = '{current_value}' for {attr}"
270-
)
271-
272-
value = int(result.groups()[0])
273-
unit = result.groups()[1]
274-
275-
# Convert to number i.e. without any unit suffix
276-
277-
if unit is not None:
278-
current_value = value * suffix[unit]
279-
else:
280-
current_value = value
281-
282-
# Convert some attributes to units that coldfront uses
283-
284-
if "RAM" in attr:
285-
current_value = round(current_value / suffix["Mi"])
286-
elif "Storage" in attr:
287-
current_value = round(current_value / suffix["Gi"])
288-
elif current_value and current_value == "0":
289-
current_value = 0
244+
current_value = utils.parse_openshift_quota_value(attr, current_value)
290245

291246
if expected_value is None and current_value is not None:
292247
msg = (

src/coldfront_plugin_cloud/openshift.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,13 @@ def get_quota(self, project_id):
200200

201201
return combined_quota
202202

203+
def get_quota_used(self, project_id):
204+
cloud_quotas = self._openshift_get_resourcequotas(project_id)
205+
combined_quota_used = {}
206+
for cloud_quota in combined_quota_used:
207+
combined_quota_used.update(cloud_quotas["status"]["used"])
208+
return combined_quota_used
209+
203210
def create_project_defaults(self, project_id):
204211
pass
205212

src/coldfront_plugin_cloud/tasks.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,3 +191,24 @@ def remove_user_from_allocation(allocation_user_pk):
191191
allocator.remove_role_from_user(username, project_id)
192192
else:
193193
logger.warning("No project has been created. Nothing to disable.")
194+
195+
196+
def get_allocation_cloud_usage(allocation_pk):
197+
"""
198+
Obtains the current quota usage for the allocation.
199+
200+
For example, the output for an Openshift quota would be:
201+
202+
{
203+
"limits.cpu": "1",
204+
"limits.memory": "2Gi",
205+
"limits.ephemeral-storage": "10Gi",
206+
}
207+
"""
208+
allocation = Allocation.objects.get(pk=allocation_pk)
209+
if allocator := find_allocator(allocation):
210+
if project_id := allocation.get_attribute(attributes.ALLOCATION_PROJECT_ID):
211+
return allocator.get_quota_used(project_id)
212+
else:
213+
logger.warning("No project has been created. No quota to check.")
214+
return

src/coldfront_plugin_cloud/tests/unit/test_utils.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,64 @@ def test_env_safe_name(self):
2929
self.assertEqual(utils.env_safe_name(42), "42")
3030
self.assertEqual(utils.env_safe_name(None), "NONE")
3131
self.assertEqual(utils.env_safe_name("hello"), "HELLO")
32+
33+
34+
class TestCheckIfQuotaAttr(base.TestCase):
35+
def test_valid_quota_attr(self):
36+
self.assertTrue(utils.check_if_quota_attr("OpenShift Limit on CPU Quota"))
37+
38+
def test_invalid_quota_attr(self):
39+
self.assertFalse(utils.check_if_quota_attr("Test"))
40+
self.assertFalse(utils.check_if_quota_attr("Allocated Project ID"))
41+
42+
43+
class TestColdfrontToOpenshiftQuotaName(base.TestCase):
44+
def test_valid_mapping(self):
45+
result = utils.coldfront_to_openshift_quota_name("OpenShift Limit on CPU Quota")
46+
self.assertEqual(result, "limits.cpu")
47+
48+
def test_missing_mapping(self):
49+
with self.assertRaises(TypeError):
50+
utils.coldfront_to_openshift_quota_name("Unknown Quota")
51+
52+
53+
class TestGetNewCloudQuota(base.TestCase):
54+
def test_get_requested_quota(self):
55+
data = [
56+
{"name": "OpenShift Limit on CPU Quota", "new_value": "2"},
57+
{"name": "OpenShift Limit on RAM Quota (MiB)", "new_value": ""},
58+
]
59+
60+
result = utils.get_new_cloud_quota(data)
61+
self.assertEqual(result, {"OpenShift Limit on CPU Quota": "2"})
62+
63+
64+
class TestCheckChangeRequests(base.TestBase):
65+
def test_check_usage(self):
66+
# No error case, usage is lower
67+
test_quota_usage = {
68+
"limits.cpu": "1",
69+
"limits.memory": "2Gi",
70+
"limits.ephemeral-storage": "10Gi", # Other quotas should be ignored
71+
"requests.storage": "40Gi",
72+
"requests.nvidia.com/gpu": "0",
73+
"persistentvolumeclaims": "4",
74+
}
75+
test_requested_quota = {"OpenShift Limit on CPU Quota": "2"}
76+
77+
self.assertEqual(
78+
[], utils.check_cloud_usage_is_lower(test_requested_quota, test_quota_usage)
79+
)
80+
81+
# Requested cpu (2) lower than current used, should return errors
82+
test_quota_usage["limits.cpu"] = "16"
83+
self.assertEqual(
84+
[
85+
(
86+
"Current quota usage for OpenShift Limit on CPU Quota "
87+
"(16) is higher than "
88+
"the requested amount (2)."
89+
)
90+
],
91+
utils.check_cloud_usage_is_lower(test_requested_quota, test_quota_usage),
92+
)

src/coldfront_plugin_cloud/utils.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
)
1414

1515
from coldfront_plugin_cloud import attributes
16+
from coldfront_plugin_cloud import openshift
1617

1718

1819
def env_safe_name(name):
@@ -244,3 +245,115 @@ def get_included_duration(
244245
total_interval_duration -= (e_interval_end - e_interval_start).total_seconds()
245246

246247
return math.ceil(total_interval_duration)
248+
249+
250+
def parse_openshift_quota_value(attr_name, quota_value):
251+
PATTERN = r"([0-9]+)(m|Ki|Mi|Gi|Ti|Pi|Ei|K|M|G|T|P|E)?"
252+
253+
suffix = {
254+
"Ki": 2**10,
255+
"Mi": 2**20,
256+
"Gi": 2**30,
257+
"Ti": 2**40,
258+
"Pi": 2**50,
259+
"Ei": 2**60,
260+
"m": 10**-3,
261+
"K": 10**3,
262+
"M": 10**6,
263+
"G": 10**9,
264+
"T": 10**12,
265+
"P": 10**15,
266+
"E": 10**18,
267+
}
268+
269+
if quota_value and quota_value != "0":
270+
result = re.search(PATTERN, quota_value)
271+
272+
if result is None:
273+
raise ValueError(
274+
f"Unable to parse quota_value = '{quota_value}' for {attr_name}"
275+
)
276+
277+
value = int(result.groups()[0])
278+
unit = result.groups()[1]
279+
280+
# Convert to number i.e. without any unit suffix
281+
282+
if unit is not None:
283+
quota_value = value * suffix[unit]
284+
else:
285+
quota_value = value
286+
287+
# Convert some attributes to units that coldfront uses
288+
289+
if "RAM" in attr_name:
290+
return round(quota_value / suffix["Mi"])
291+
elif "Storage" in attr_name:
292+
return round(quota_value / suffix["Gi"])
293+
return quota_value
294+
elif quota_value and quota_value == "0":
295+
return 0
296+
297+
298+
def check_if_quota_attr(attr_name: str):
299+
for quota_attr in attributes.ALLOCATION_QUOTA_ATTRIBUTES:
300+
if attr_name == quota_attr.name:
301+
return True
302+
return False
303+
304+
305+
def coldfront_to_openshift_quota_name(coldfront_quota_name: str):
306+
"""Converts the Coldfront quota name to OpenShift equivalent.
307+
308+
:param coldfront_quota_name: Coldfront quota name.
309+
:return: OpenShift equivalent quota name.
310+
"""
311+
quota_key_lambda_func = openshift.OpenShiftResourceAllocator.QUOTA_KEY_MAPPING.get(
312+
coldfront_quota_name, None
313+
)
314+
return list(quota_key_lambda_func(1).keys())[0]
315+
316+
317+
def get_new_cloud_quota(change_request_data: list[dict[str, str]]):
318+
"""
319+
Converts change request data to a dictionary of requested quota changes.
320+
Ignores attributes with empty `new_value` str, meaning no change requested for them
321+
Input typically looks like:
322+
[
323+
{
324+
"name": "OpenShift Limit on CPU Quota",
325+
"new_value": "2",
326+
...
327+
},
328+
{
329+
"name": "OpenShift Limit on RAM Quota (MiB)",
330+
"new_value": "",
331+
...
332+
}
333+
]
334+
"""
335+
requested_quota = {}
336+
for form in change_request_data:
337+
if check_if_quota_attr(form["name"]) and form["new_value"]:
338+
requested_quota[form["name"]] = form["new_value"]
339+
return requested_quota
340+
341+
342+
def check_cloud_usage_is_lower(
343+
requested_quota: dict[str, str], cloud_quota_usage: dict[str, str]
344+
):
345+
usage_errors = []
346+
for quota_name, requested_quota_value in requested_quota.items():
347+
cloud_quota_name = coldfront_to_openshift_quota_name(quota_name)
348+
if int(requested_quota_value) < parse_openshift_quota_value(
349+
quota_name, cloud_quota_usage[cloud_quota_name]
350+
):
351+
usage_errors.append(
352+
(
353+
f"Current quota usage for {quota_name} "
354+
f"({cloud_quota_usage[cloud_quota_name]}) is higher than "
355+
f"the requested amount ({requested_quota_value})."
356+
)
357+
)
358+
359+
return usage_errors

0 commit comments

Comments
 (0)