Skip to content

Commit a4070a3

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 a4070a3

File tree

7 files changed

+220
-48
lines changed

7 files changed

+220
-48
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: 68 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,66 @@ 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+
96+
def coldfront_to_openshift_quota_name(coldfront_quota_name: str):
97+
"""Converts the Coldfront quota name to OpenShift equivalent.
98+
99+
:param coldfront_quota_name: Coldfront quota name.
100+
:return: OpenShift equivalent quota name.
101+
"""
102+
quota_key_lambda_func = OpenShiftResourceAllocator.QUOTA_KEY_MAPPING.get(
103+
coldfront_quota_name, None
104+
)
105+
return list(quota_key_lambda_func(1).keys())[0]
106+
107+
47108
class ApiException(Exception):
48109
def __init__(self, message):
49110
self.message = message
@@ -200,6 +261,13 @@ def get_quota(self, project_id):
200261

201262
return combined_quota
202263

264+
def get_usage(self, project_id):
265+
cloud_quotas = self._openshift_get_resourcequotas(project_id)
266+
combined_quota_used = {}
267+
for cloud_quota in combined_quota_used:
268+
combined_quota_used.update(cloud_quotas["status"]["used"])
269+
return combined_quota_used
270+
203271
def create_project_defaults(self, project_id):
204272
pass
205273

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/test_utils.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from random import randrange
44

55
from coldfront_plugin_cloud.tests import base
6-
from coldfront_plugin_cloud import utils
6+
from coldfront_plugin_cloud import utils, openshift
77

88

99
class TestGetSanitizedProjectName(base.TestBase):
@@ -29,3 +29,66 @@ 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 = openshift.coldfront_to_openshift_quota_name(
46+
"OpenShift Limit on CPU Quota"
47+
)
48+
self.assertEqual(result, "limits.cpu")
49+
50+
def test_missing_mapping(self):
51+
with self.assertRaises(TypeError):
52+
openshift.coldfront_to_openshift_quota_name("Unknown Quota")
53+
54+
55+
class TestGetNewCloudQuota(base.TestCase):
56+
def test_get_requested_quota(self):
57+
data = [
58+
{"name": "OpenShift Limit on CPU Quota", "new_value": "2"},
59+
{"name": "OpenShift Limit on RAM Quota (MiB)", "new_value": ""},
60+
]
61+
62+
result = utils.get_new_cloud_quota(data)
63+
self.assertEqual(result, {"OpenShift Limit on CPU Quota": "2"})
64+
65+
66+
class TestCheckChangeRequests(base.TestBase):
67+
def test_check_usage(self):
68+
# No error case, usage is lower
69+
test_quota_usage = {
70+
"limits.cpu": "1",
71+
"limits.memory": "2Gi",
72+
"limits.ephemeral-storage": "10Gi", # Other quotas should be ignored
73+
"requests.storage": "40Gi",
74+
"requests.nvidia.com/gpu": "0",
75+
"persistentvolumeclaims": "4",
76+
}
77+
test_requested_quota = {"OpenShift Limit on CPU Quota": "2"}
78+
79+
self.assertEqual(
80+
[], utils.check_cloud_usage_is_lower(test_requested_quota, test_quota_usage)
81+
)
82+
83+
# Requested cpu (2) lower than current used, should return errors
84+
test_quota_usage["limits.cpu"] = "16"
85+
self.assertEqual(
86+
[
87+
(
88+
"Current quota usage for OpenShift Limit on CPU Quota "
89+
"(16) is higher than "
90+
"the requested amount (2)."
91+
)
92+
],
93+
utils.check_cloud_usage_is_lower(test_requested_quota, test_quota_usage),
94+
)

src/coldfront_plugin_cloud/utils.py

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

0 commit comments

Comments
 (0)