Skip to content

Commit 762e452

Browse files
committed
Validate Openshift limit ranges
A function `set_project_configuration()` has been added to `openshift.py` to validate limit ranges. This allows changes to the default limit ranges (`LIMITRANGE_DEFAULTS`) to be applied to pre-existing Openshift projects. The plan will be for this function to eventually contain all validation logic for Openshift project configurations, allowing `validation_allocations` to be resource-agnostic. New functional test case added `parse_quota_value` has been moved to `utils.py` as it is now used in two places
1 parent 44e1933 commit 762e452

File tree

7 files changed

+258
-82
lines changed

7 files changed

+258
-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: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import logging
44
import os
55
import time
6+
import re
7+
import copy
68

79
import kubernetes
810
import kubernetes.dynamic.exceptions as kexc
@@ -54,6 +56,94 @@ def clean_openshift_metadata(obj):
5456
return obj
5557

5658

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

223+
def set_project_configuration(self, project_id, dry_run=False):
224+
limits = self._openshift_get_limits(project_id).get("items", [])
225+
226+
if not limits:
227+
if not dry_run:
228+
self._openshift_create_limits(project_id)
229+
logger.info(f"Created default LimitRange for namespace {project_id}.")
230+
231+
elif len(limits) > 1:
232+
logger.warning(
233+
f"More than one LimitRange found for namespace {project_id}."
234+
)
235+
if not dry_run:
236+
self._openshift_delete_limits(project_id)
237+
self._openshift_create_limits(project_id)
238+
logger.info(f"Recreated LimitRanges for namespace {project_id}.")
239+
240+
if len(limits) == 1:
241+
actual_limits = limits[0]["spec"]["limits"]
242+
differences = limit_ranges_diff(
243+
LIMITRANGE_DEFAULTS, actual_limits, project_id
244+
)
245+
246+
if differences:
247+
for message in differences:
248+
logger.info(message)
249+
if not dry_run:
250+
self._openshift_delete_limits(project_id)
251+
self._openshift_create_limits(project_id)
252+
logger.info(f"Recreated LimitRanges for namespace {project_id}.")
253+
133254
def create_project(self, suggested_project_name):
134255
sanitized_project_name = utils.get_sanitized_project_name(
135256
suggested_project_name
@@ -446,6 +567,13 @@ def _openshift_create_limits(self, project_name, limits=None):
446567
}
447568
return api.create(body=payload, namespace=project_name).to_dict()
448569

570+
def _openshift_delete_limits(self, project_name):
571+
api = self.get_resource_api(API_CORE, "LimitRange")
572+
573+
limit_ranges = self._openshift_get_limits(project_name)
574+
for lr in limit_ranges["items"]:
575+
api.delete(namespace=project_name, name=lr["metadata"]["name"])
576+
449577
def _openshift_get_namespace(self, namespace_name):
450578
api = self.get_resource_api(API_CORE, "Namespace")
451579
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+
)
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import copy
2+
3+
from coldfront_plugin_cloud import openshift
4+
from coldfront_plugin_cloud.tests import base
5+
6+
7+
class TestOpenShiftUtils(base.TestBase):
8+
def test_parse_quota_unit(self):
9+
parse_quota_unit = openshift.parse_quota_value
10+
answer_dict = [
11+
(("5m", "cpu"), 5 * 10**-3),
12+
(("10", "cpu"), 10),
13+
(("10k", "cpu"), 10 * 10**3),
14+
(("55M", "cpu"), 55 * 10**6),
15+
(("2G", "cpu"), 2 * 10**9),
16+
(("3T", "cpu"), 3 * 10**12),
17+
(("4P", "cpu"), 4 * 10**15),
18+
(("5E", "cpu"), 5 * 10**18),
19+
(("10", "memory"), 10),
20+
(("125Ki", "memory"), 125 * 2**10),
21+
(("55Mi", "memory"), 55 * 2**20),
22+
(("2Gi", "memory"), 2 * 2**30),
23+
(("3Ti", "memory"), 3 * 2**40),
24+
]
25+
for (input_value, resource_type), expected in answer_dict:
26+
self.assertEqual(parse_quota_unit(input_value, resource_type), expected)
27+
28+
with self.assertRaises(ValueError):
29+
parse_quota_unit("abc", "foo") # Non-numeric input
30+
31+
def test_limit_ranges_diff(self):
32+
# identical limit ranges, different units for memory -> no differences
33+
expected = openshift.LIMITRANGE_DEFAULTS
34+
actual = [copy.deepcopy(expected[0])]
35+
actual[0]["default"]["memory"] = "4Gi"
36+
diffs = openshift.limit_ranges_diff(expected, actual, project_id="foo")
37+
self.assertEqual(diffs, [])
38+
39+
# type mismatch, [default][cpu] value mismatch (1 vs 2), and missing [min][memory]
40+
actual[0]["type"] = "Pod"
41+
actual[0]["default"]["cpu"] = "2"
42+
del actual[0]["min"]["memory"]
43+
diffs = openshift.limit_ranges_diff(expected, actual, project_id="foo")
44+
self.assertTrue(any("expected Container but found Pod" in d for d in diffs))
45+
self.assertTrue(
46+
any("differs [default][cpu]: expected 1 but found 2" in d for d in diffs)
47+
)
48+
self.assertTrue(any("missing [min][memory]" in d for d in diffs))
49+
50+
# Contains extra fields
51+
actual = [copy.deepcopy(expected[0])]
52+
actual[0]["default"]["ephemeral-storage"] = "10Gi"
53+
diffs = openshift.limit_ranges_diff(expected, actual, project_id="foo")
54+
self.assertTrue(any("contains non-default fields" in d for d in diffs))

0 commit comments

Comments
 (0)