Skip to content

Commit 1775cb4

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 1775cb4

File tree

8 files changed

+227
-47
lines changed

8 files changed

+227
-47
lines changed

src/coldfront_plugin_cloud/base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,7 @@ def assign_role_on_user(self, username, project_id):
7777
@abc.abstractmethod
7878
def remove_role_from_user(self, username, project_id):
7979
pass
80+
81+
@abc.abstractmethod
82+
def get_usage(self, project_id):
83+
pass

src/coldfront_plugin_cloud/management/commands/validate_allocations.py

Lines changed: 4 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,9 @@ 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 = openshift.parse_openshift_quota_value(
245+
attr, current_value
246+
)
290247

291248
if expected_value is None and current_value is not None:
292249
msg = (

src/coldfront_plugin_cloud/openshift.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import json
33
import logging
44
import os
5+
import re
56
import requests
67
from requests.auth import HTTPBasicAuth
78
import time
@@ -44,6 +45,54 @@ def clean_openshift_metadata(obj):
4445
return obj
4546

4647

48+
def parse_openshift_quota_value(attr_name, quota_value):
49+
PATTERN = r"([0-9]+)(m|Ki|Mi|Gi|Ti|Pi|Ei|K|M|G|T|P|E)?"
50+
51+
suffix = {
52+
"Ki": 2**10,
53+
"Mi": 2**20,
54+
"Gi": 2**30,
55+
"Ti": 2**40,
56+
"Pi": 2**50,
57+
"Ei": 2**60,
58+
"m": 10**-3,
59+
"K": 10**3,
60+
"M": 10**6,
61+
"G": 10**9,
62+
"T": 10**12,
63+
"P": 10**15,
64+
"E": 10**18,
65+
}
66+
67+
if quota_value and quota_value != "0":
68+
result = re.search(PATTERN, quota_value)
69+
70+
if result is None:
71+
raise ValueError(
72+
f"Unable to parse quota_value = '{quota_value}' for {attr_name}"
73+
)
74+
75+
value = int(result.groups()[0])
76+
unit = result.groups()[1]
77+
78+
# Convert to number i.e. without any unit suffix
79+
80+
if unit is not None:
81+
quota_value = value * suffix[unit]
82+
else:
83+
quota_value = value
84+
85+
# Convert some attributes to units that coldfront uses
86+
87+
if "RAM" in attr_name:
88+
return round(quota_value / suffix["Mi"])
89+
elif "Storage" in attr_name:
90+
return round(quota_value / suffix["Gi"])
91+
return quota_value
92+
elif quota_value and quota_value == "0":
93+
return 0
94+
95+
4796
class ApiException(Exception):
4897
def __init__(self, message):
4998
self.message = message
@@ -69,6 +118,11 @@ class OpenShiftResourceAllocator(base.ResourceAllocator):
69118
attributes.QUOTA_PVC: lambda x: {"persistentvolumeclaims": f"{x}"},
70119
}
71120

121+
CF_QUOTA_KEY_MAPPING = {
122+
list(quota_lambda_func(0).keys())[0]: cf_attr
123+
for cf_attr, quota_lambda_func in QUOTA_KEY_MAPPING.items()
124+
}
125+
72126
resource_type = "openshift"
73127

74128
project_name_max_length = 63
@@ -200,6 +254,21 @@ def get_quota(self, project_id):
200254

201255
return combined_quota
202256

257+
def get_usage(self, project_id):
258+
cloud_quotas = self._openshift_get_resourcequotas(project_id)
259+
combined_quota_used = {}
260+
for cloud_quota in cloud_quotas:
261+
combined_quota_used.update(
262+
{
263+
self.CF_QUOTA_KEY_MAPPING[quota_key]: parse_openshift_quota_value(
264+
self.CF_QUOTA_KEY_MAPPING[quota_key], value
265+
)
266+
for quota_key, value in cloud_quota["status"]["used"].items()
267+
}
268+
)
269+
270+
return combined_quota_used
271+
203272
def create_project_defaults(self, project_id):
204273
pass
205274

src/coldfront_plugin_cloud/openstack.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,3 +470,6 @@ def get_users(self, project_id):
470470
role_assignment.user["name"] for role_assignment in role_assignments
471471
)
472472
return user_names
473+
474+
def get_usage(self, project_id):
475+
raise NotImplementedError

src/coldfront_plugin_cloud/tasks.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,3 +191,27 @@ 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+
try:
212+
return allocator.get_usage(project_id)
213+
except NotImplemented:
214+
return
215+
else:
216+
logger.warning("No project has been created. No quota to check.")
217+
return

src/coldfront_plugin_cloud/tests/unit/openshift/test_quota.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,25 @@ def test_get_moc_quota(self, fake_get_quota):
109109
]
110110
res = self.allocator.get_quota("fake-project")
111111
self.assertEqual(res, expected_quota)
112+
113+
@mock.patch(
114+
"coldfront_plugin_cloud.openshift.OpenShiftResourceAllocator._openshift_get_resourcequotas"
115+
)
116+
def test_get_moc_usage(self, fake_get_quota):
117+
fake_usage = {
118+
"limits.cpu": "2000m",
119+
"limits.memory": "4096Mi",
120+
}
121+
fake_get_quota.return_value = [
122+
{
123+
"status": {"used": fake_usage},
124+
}
125+
]
126+
res = self.allocator.get_usage("fake-project")
127+
self.assertEqual(
128+
res,
129+
{
130+
"OpenShift Limit on CPU Quota": 2.0,
131+
"OpenShift Limit on RAM Quota (MiB)": 4096,
132+
},
133+
)

src/coldfront_plugin_cloud/tests/unit/test_utils.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,54 @@ 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 TestGetNewCloudQuota(base.TestCase):
44+
def test_get_requested_quota(self):
45+
data = [
46+
{"name": "OpenShift Limit on CPU Quota", "new_value": "2"},
47+
{"name": "OpenShift Limit on RAM Quota (MiB)", "new_value": ""},
48+
]
49+
50+
result = utils.get_new_cloud_quota(data)
51+
self.assertEqual(result, {"OpenShift Limit on CPU Quota": "2"})
52+
53+
54+
class TestCheckChangeRequests(base.TestBase):
55+
def test_check_usage(self):
56+
# No error case, usage is lower
57+
test_quota_usage = {
58+
"limits.cpu": "1",
59+
"limits.memory": "2Gi",
60+
"limits.ephemeral-storage": "10Gi", # Other quotas should be ignored
61+
"requests.storage": "40Gi",
62+
"requests.nvidia.com/gpu": "0",
63+
"persistentvolumeclaims": "4",
64+
}
65+
test_requested_quota = {"OpenShift Limit on CPU Quota": "2"}
66+
67+
self.assertEqual(
68+
[], utils.check_cloud_usage_is_lower(test_requested_quota, test_quota_usage)
69+
)
70+
71+
# Requested cpu (2) lower than current used, should return errors
72+
test_quota_usage["limits.cpu"] = "16"
73+
self.assertEqual(
74+
[
75+
(
76+
"Current quota usage for OpenShift Limit on CPU Quota "
77+
"(16) is higher than "
78+
"the requested amount (2)."
79+
)
80+
],
81+
utils.check_cloud_usage_is_lower(test_requested_quota, test_quota_usage),
82+
)

src/coldfront_plugin_cloud/utils.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,3 +244,53 @@ def get_included_duration(
244244
total_interval_duration -= (e_interval_end - e_interval_start).total_seconds()
245245

246246
return math.ceil(total_interval_duration)
247+
248+
249+
def check_if_quota_attr(attr_name: str):
250+
for quota_attr in attributes.ALLOCATION_QUOTA_ATTRIBUTES:
251+
if attr_name == quota_attr.name:
252+
return True
253+
return False
254+
255+
256+
def get_new_cloud_quota(change_request_data: list[dict[str, str]]):
257+
"""
258+
Converts change request data to a dictionary of requested quota changes.
259+
Ignores attributes with empty `new_value` str, meaning no change requested for them
260+
Input typically looks like:
261+
[
262+
{
263+
"name": "OpenShift Limit on CPU Quota",
264+
"new_value": "2",
265+
...
266+
},
267+
{
268+
"name": "OpenShift Limit on RAM Quota (MiB)",
269+
"new_value": "",
270+
...
271+
}
272+
]
273+
"""
274+
requested_quota = {}
275+
for form in change_request_data:
276+
if check_if_quota_attr(form["name"]) and form["new_value"]:
277+
requested_quota[form["name"]] = form["new_value"]
278+
return requested_quota
279+
280+
281+
def check_cloud_usage_is_lower(
282+
requested_quota: dict[str, str], cloud_quota_usage: dict[str, str]
283+
):
284+
usage_errors = []
285+
for quota_name, requested_quota_value in requested_quota.items():
286+
current_usage_value = cloud_quota_usage[quota_name]
287+
if int(requested_quota_value) < current_usage_value:
288+
usage_errors.append(
289+
(
290+
f"Current quota usage for {quota_name} "
291+
f"({current_usage_value}) is higher than "
292+
f"the requested amount ({requested_quota_value})."
293+
)
294+
)
295+
296+
return usage_errors

0 commit comments

Comments
 (0)