Skip to content

Commit 772cd9f

Browse files
authored
Merge pull request #264 from QuanMPhm/257/limit_ranges
Validate Openshift limit ranges
2 parents 198ebe3 + df2cc66 commit 772cd9f

File tree

7 files changed

+275
-82
lines changed

7 files changed

+275
-82
lines changed

src/coldfront_plugin_cloud/base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ def auth_url(self):
3838
def member_role_name(self):
3939
return self.resource.get_attribute(attributes.RESOURCE_ROLE) or "member"
4040

41+
@abc.abstractmethod
42+
def set_project_configuration(self, project_id, dry_run=False):
43+
pass
44+
4145
@abc.abstractmethod
4246
def create_project(self, suggested_project_name) -> Project:
4347
pass

src/coldfront_plugin_cloud/management/commands/validate_allocations.py

Lines changed: 6 additions & 53 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,
@@ -92,56 +91,6 @@ def set_default_quota_on_allocation(allocation, allocator, coldfront_attr):
9291
utils.set_attribute_on_allocation(allocation, coldfront_attr, value)
9392
return value
9493

95-
@staticmethod
96-
def parse_quota_value(quota_str: str | None, attr: str) -> int | None:
97-
PATTERN = r"([0-9]+)(m|k|Ki|Mi|Gi|Ti|Pi|Ei|K|M|G|T|P|E)?"
98-
99-
suffix = {
100-
"Ki": 2**10,
101-
"Mi": 2**20,
102-
"Gi": 2**30,
103-
"Ti": 2**40,
104-
"Pi": 2**50,
105-
"Ei": 2**60,
106-
"m": 10**-3,
107-
"k": 10**3,
108-
"K": 10**3,
109-
"M": 10**6,
110-
"G": 10**9,
111-
"T": 10**12,
112-
"P": 10**15,
113-
"E": 10**18,
114-
}
115-
116-
if quota_str and quota_str != "0":
117-
result = re.search(PATTERN, quota_str)
118-
119-
if result is None:
120-
raise CommandError(
121-
f"Unable to parse quota_str = '{quota_str}' for {attr}"
122-
)
123-
124-
value = int(result.groups()[0])
125-
unit = result.groups()[1]
126-
127-
# Convert to number i.e. without any unit suffix
128-
129-
if unit is not None:
130-
quota_str = value * suffix[unit]
131-
else:
132-
quota_str = value
133-
134-
# Convert some attributes to units that coldfront uses
135-
136-
if "RAM" in attr:
137-
quota_str = round(quota_str / suffix["Mi"])
138-
elif "Storage" in attr:
139-
quota_str = round(quota_str / suffix["Gi"])
140-
elif quota_str and quota_str == "0":
141-
quota_str = 0
142-
143-
return quota_str
144-
14594
def check_institution_specific_code(self, allocation, apply):
14695
attr = attributes.ALLOCATION_INSTITUTION_SPECIFIC_CODE
14796
isc = allocation.get_attribute(attr)
@@ -289,6 +238,10 @@ def handle(self, *args, **options):
289238
)
290239
continue
291240

241+
allocator.set_project_configuration(
242+
project_id, dry_run=not options["apply"]
243+
)
244+
292245
quota = allocator.get_quota(project_id)
293246

294247
failed_validation = Command.sync_users(
@@ -306,7 +259,7 @@ def handle(self, *args, **options):
306259

307260
expected_value = allocation.get_attribute(attr)
308261
current_value = quota.get(key, None)
309-
current_value = self.parse_quota_value(current_value, attr)
262+
current_value = openshift.parse_quota_value(current_value, attr)
310263

311264
if expected_value is None and current_value is not None:
312265
msg = (

src/coldfront_plugin_cloud/openshift.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
import logging
44
import os
55
import time
6+
import re
7+
import copy
8+
from collections import namedtuple
69

710
import kubernetes
811
import kubernetes.dynamic.exceptions as kexc
@@ -54,6 +57,96 @@ def clean_openshift_metadata(obj):
5457
return obj
5558

5659

60+
def parse_quota_value(quota_str: str | None, attr: str) -> int | None:
61+
PATTERN = r"([0-9]+)(m|k|Ki|Mi|Gi|Ti|Pi|Ei|K|M|G|T|P|E)?"
62+
63+
suffix = {
64+
"Ki": 2**10,
65+
"Mi": 2**20,
66+
"Gi": 2**30,
67+
"Ti": 2**40,
68+
"Pi": 2**50,
69+
"Ei": 2**60,
70+
"m": 10**-3,
71+
"k": 10**3,
72+
"K": 10**3,
73+
"M": 10**6,
74+
"G": 10**9,
75+
"T": 10**12,
76+
"P": 10**15,
77+
"E": 10**18,
78+
}
79+
80+
if quota_str and quota_str != "0":
81+
result = re.search(PATTERN, quota_str)
82+
83+
if result is None:
84+
raise ValueError(f"Unable to parse quota_str = '{quota_str}' for {attr}")
85+
86+
value = int(result.groups()[0])
87+
unit = result.groups()[1]
88+
89+
# Convert to number i.e. without any unit suffix
90+
91+
if unit is not None:
92+
quota_str = value * suffix[unit]
93+
else:
94+
quota_str = value
95+
96+
# Convert some attributes to units that coldfront uses
97+
98+
if "RAM" in attr:
99+
quota_str = round(quota_str / suffix["Mi"])
100+
elif "Storage" in attr:
101+
quota_str = round(quota_str / suffix["Gi"])
102+
elif quota_str and quota_str == "0":
103+
quota_str = 0
104+
105+
return quota_str
106+
107+
108+
LimitRangeDifference = namedtuple("LimitRangeDifference", ["key", "expected", "actual"])
109+
110+
111+
def limit_ranges_diff(
112+
expected_lr_list: list[dict], actual_lr_list: list[dict]
113+
) -> list[LimitRangeDifference]:
114+
expected_lr = copy.deepcopy(expected_lr_list[0])
115+
actual_lr = copy.deepcopy(actual_lr_list[0])
116+
differences = []
117+
118+
for key in expected_lr | actual_lr:
119+
if key == "type":
120+
if actual_lr.get(key) != expected_lr.get(key):
121+
differences.append(
122+
LimitRangeDifference(
123+
key, expected_lr.get(key), actual_lr.get("type")
124+
)
125+
)
126+
break
127+
continue
128+
129+
# Extra fields in actual limit range, so else statement should only be expected fields
130+
if key not in expected_lr:
131+
differences.append(LimitRangeDifference(key, None, actual_lr[key]))
132+
else:
133+
for resource in expected_lr.setdefault(key, {}) | actual_lr.setdefault(
134+
key, {}
135+
):
136+
expected_value = parse_quota_value(
137+
expected_lr[key].get(resource), resource
138+
)
139+
actual_value = parse_quota_value(actual_lr[key].get(resource), resource)
140+
if expected_value != actual_value:
141+
differences.append(
142+
LimitRangeDifference(
143+
f"{key},{resource}", expected_value, actual_value
144+
)
145+
)
146+
147+
return differences
148+
149+
57150
class ApiException(Exception):
58151
def __init__(self, message):
59152
self.message = message
@@ -130,6 +223,40 @@ def get_resource_api(self, api_version: str, kind: str):
130223
)
131224
return api
132225

226+
def set_project_configuration(self, project_id, dry_run=False):
227+
def _recreate_limitrange():
228+
if not dry_run:
229+
self._openshift_delete_limits(project_id)
230+
self._openshift_create_limits(project_id)
231+
logger.info(f"Recreated LimitRanges for namespace {project_id}.")
232+
233+
limits = self._openshift_get_limits(project_id).get("items", [])
234+
235+
if not limits:
236+
if not dry_run:
237+
self._openshift_create_limits(project_id)
238+
logger.info(f"Created default LimitRange for namespace {project_id}.")
239+
240+
elif len(limits) > 1:
241+
logger.warning(
242+
f"More than one LimitRange found for namespace {project_id}."
243+
)
244+
_recreate_limitrange()
245+
246+
if len(limits) == 1:
247+
actual_limits = limits[0]["spec"]["limits"]
248+
if len(actual_limits) != 1:
249+
logger.info(
250+
f"LimitRange for more than one object type found for namespace {project_id}."
251+
)
252+
_recreate_limitrange()
253+
elif differences := limit_ranges_diff(LIMITRANGE_DEFAULTS, actual_limits):
254+
for difference in differences:
255+
logger.info(
256+
f"LimitRange for {project_id} differs {difference.key}: expected {difference.expected} but found {difference.actual}"
257+
)
258+
_recreate_limitrange()
259+
133260
def create_project(self, suggested_project_name):
134261
sanitized_project_name = utils.get_sanitized_project_name(
135262
suggested_project_name
@@ -446,6 +573,13 @@ def _openshift_create_limits(self, project_name, limits=None):
446573
}
447574
return api.create(body=payload, namespace=project_name).to_dict()
448575

576+
def _openshift_delete_limits(self, project_name):
577+
api = self.get_resource_api(API_CORE, "LimitRange")
578+
579+
limit_ranges = self._openshift_get_limits(project_name)
580+
for lr in limit_ranges["items"]:
581+
api.delete(namespace=project_name, name=lr["metadata"]["name"])
582+
449583
def _openshift_get_namespace(self, namespace_name):
450584
api = self.get_resource_api(API_CORE, "Namespace")
451585
return clean_openshift_metadata(api.get(name=namespace_name).to_dict())

src/coldfront_plugin_cloud/openstack.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@ def object(self, project_id=None, session=None) -> swiftclient.Connection:
141141
preauthurl=preauth_url,
142142
)
143143

144+
def set_project_configuration(self, project_id, dry_run=False):
145+
pass
146+
144147
def create_project(self, suggested_project_name) -> base.ResourceAllocator.Project:
145148
project_name = utils.get_unique_project_name(
146149
suggested_project_name, max_length=self.project_name_max_length

src/coldfront_plugin_cloud/tests/functional/openshift/test_allocation.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,3 +503,66 @@ def test_needs_renewal_allocation(self):
503503
assert user2.username not in allocator.get_users(project_id)
504504
call_command("validate_allocations", apply=True)
505505
assert user2.username in allocator.get_users(project_id)
506+
507+
def test_limitrange_defaults_update(self):
508+
"""Test validation if default LimitRange changes, or actual LimitRange is deleted."""
509+
user = self.new_user()
510+
project = self.new_project(pi=user)
511+
allocation = self.new_allocation(project, self.resource, 1)
512+
allocator = openshift.OpenShiftResourceAllocator(self.resource, allocation)
513+
514+
tasks.activate_allocation(allocation.pk)
515+
allocation.refresh_from_db()
516+
517+
project_id = allocation.get_attribute(attributes.ALLOCATION_PROJECT_ID)
518+
519+
# Check initial limit ranges
520+
limit_ranges = allocator._openshift_get_limits(project_id)
521+
self.assertEqual(len(limit_ranges["items"]), 1)
522+
self.assertEqual(
523+
openshift.limit_ranges_diff(
524+
limit_ranges["items"][0]["spec"]["limits"],
525+
openshift.LIMITRANGE_DEFAULTS,
526+
),
527+
[],
528+
)
529+
530+
# Change LimitRange defaults
531+
new_defaults = [
532+
{
533+
"type": "Container",
534+
"default": {"cpu": "2", "memory": "8192Mi", "nvidia.com/gpu": "1"},
535+
"defaultRequest": {
536+
"cpu": "1",
537+
"memory": "4096Mi",
538+
"nvidia.com/gpu": "1",
539+
},
540+
"min": {"cpu": "100m", "memory": "64Mi"},
541+
}
542+
]
543+
openshift.LIMITRANGE_DEFAULTS = new_defaults
544+
545+
call_command("validate_allocations", apply=True)
546+
547+
limit_ranges = allocator._openshift_get_limits(project_id)
548+
self.assertEqual(len(limit_ranges["items"]), 1)
549+
self.assertEqual(
550+
openshift.limit_ranges_diff(
551+
limit_ranges["items"][0]["spec"]["limits"], new_defaults
552+
),
553+
[],
554+
)
555+
556+
# Delete and re-create limit range using validate_allocations
557+
allocator._openshift_delete_limits(project_id)
558+
limit_ranges = allocator._openshift_get_limits(project_id)
559+
self.assertEqual(len(limit_ranges["items"]), 0)
560+
call_command("validate_allocations", apply=True)
561+
limit_ranges = allocator._openshift_get_limits(project_id)
562+
self.assertEqual(len(limit_ranges["items"]), 1)
563+
self.assertEqual(
564+
openshift.limit_ranges_diff(
565+
limit_ranges["items"][0]["spec"]["limits"], new_defaults
566+
),
567+
[],
568+
)

0 commit comments

Comments
 (0)