Skip to content

Commit ca050e9

Browse files
committed
Allow direct communication with Openshift Quota API
The Openshift allocator will now only make the minimal `resourcequota` object for each namespace, with no support for scopes. Most of the integration code and test cases have been adapted from `openshift-acct-mgt`. Notable exclusions were any code related to the `quota.json`[1], `limits.json`[2], and quota scopes[3]. [1] https://github.com/CCI-MOC/openshift-acct-mgt/blob/master/k8s/base/quotas.json [2] https://github.com/CCI-MOC/openshift-acct-mgt/blob/master/k8s/base/limits.json [3] https://github.com/CCI-MOC/openshift-acct-mgt/blob/42db8f80962fd355eac1bc80a1894dc6bb824f12/acct_mgt/moc_openshift.py#L418-L431
1 parent 49bca1d commit ca050e9

4 files changed

Lines changed: 210 additions & 47 deletions

File tree

src/coldfront_plugin_cloud/management/commands/validate_allocations.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ def handle(self, *args, **options):
183183
)
184184
continue
185185

186-
quota = allocator.get_quota(project_id)["Quota"]
186+
quota = allocator.get_quota(project_id)
187187

188188
failed_validation = Command.sync_users(project_id, allocation, allocator, options["apply"])
189189

src/coldfront_plugin_cloud/openshift.py

Lines changed: 82 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,12 @@ def clean_openshift_metadata(obj):
3636
return obj
3737

3838
QUOTA_KEY_MAPPING = {
39-
attributes.QUOTA_LIMITS_CPU: lambda x: {":limits.cpu": f"{x * 1000}m"},
40-
attributes.QUOTA_LIMITS_MEMORY: lambda x: {":limits.memory": f"{x}Mi"},
41-
attributes.QUOTA_LIMITS_EPHEMERAL_STORAGE_GB: lambda x: {":limits.ephemeral-storage": f"{x}Gi"},
42-
attributes.QUOTA_REQUESTS_STORAGE: lambda x: {":requests.storage": f"{x}Gi"},
43-
attributes.QUOTA_REQUESTS_GPU: lambda x: {":requests.nvidia.com/gpu": f"{x}"},
44-
attributes.QUOTA_PVC: lambda x: {":persistentvolumeclaims": f"{x}"},
39+
attributes.QUOTA_LIMITS_CPU: lambda x: {"limits.cpu": f"{x * 1000}m"},
40+
attributes.QUOTA_LIMITS_MEMORY: lambda x: {"limits.memory": f"{x}Mi"},
41+
attributes.QUOTA_LIMITS_EPHEMERAL_STORAGE_GB: lambda x: {"limits.ephemeral-storage": f"{x}Gi"},
42+
attributes.QUOTA_REQUESTS_STORAGE: lambda x: {"requests.storage": f"{x}Gi"},
43+
attributes.QUOTA_REQUESTS_GPU: lambda x: {"requests.nvidia.com/gpu": f"{x}"},
44+
attributes.QUOTA_PVC: lambda x: {"persistentvolumeclaims": f"{x}"},
4545
}
4646

4747

@@ -146,20 +146,43 @@ def create_project(self, suggested_project_name):
146146
project_name = project_id
147147
self._create_project(project_name, project_id)
148148
return self.Project(project_name, project_id)
149+
150+
def delete_moc_quotas(self, project_id):
151+
"""deletes all resourcequotas from an openshift project"""
152+
resourcequotas = self._openshift_get_resourcequotas(project_id)
153+
for resourcequota in resourcequotas:
154+
self._openshift_delete_resourcequota(project_id, resourcequota["metadata"]["name"])
155+
156+
logger.info(f"All quotas for {project_id} successfully deleted")
149157

150158
def set_quota(self, project_id):
151-
url = f"{self.auth_url}/projects/{project_id}/quota"
152-
payload = dict()
159+
"""Sets the quota for a project, creating a minimal resourcequota
160+
object in the project namespace with no extra scopes"""
161+
162+
quota_spec = {}
153163
for key, func in QUOTA_KEY_MAPPING.items():
154164
if (x := self.allocation.get_attribute(key)) is not None:
155-
payload.update(func(x))
156-
r = self.session.put(url, data=json.dumps({'Quota': payload}))
157-
self.check_response(r)
165+
quota_spec.update(func(x))
166+
167+
quota_def = {
168+
"metadata": {"name": f"{project_id}-project"},
169+
"spec": {"hard": quota_spec},
170+
}
171+
172+
self.delete_moc_quotas(project_id)
173+
self._openshift_create_resourcequota(project_id, quota_def)
174+
175+
logger.info(f"Quota for {project_id} successfully created")
158176

159177
def get_quota(self, project_id):
160-
url = f"{self.auth_url}/projects/{project_id}/quota"
161-
r = self.session.get(url)
162-
return self.check_response(r)
178+
cloud_quotas = self._openshift_get_resourcequotas(project_id)
179+
combined_quota = {}
180+
# TODO: {Quan} Do our project namespace have more than one quota object?
181+
# What should I do??
182+
for cloud_quota in cloud_quotas:
183+
combined_quota.update(cloud_quota["spec"]["hard"])
184+
185+
return combined_quota
163186

164187
def create_project_defaults(self, project_id):
165188
pass
@@ -300,3 +323,48 @@ def _openshift_useridentitymapping_exists(self, user_name, id_user):
300323
for identity in user.get("identities", [])
301324
)
302325

326+
def _openshift_get_project(self, project_name):
327+
api = self.get_resource_api(API_PROJECT, "Project")
328+
return clean_openshift_metadata(api.get(name=project_name).to_dict())
329+
330+
def _openshift_get_resourcequotas(self, project_id):
331+
"""Returns a list of resourcequota objects in namespace with name `project_id`"""
332+
# Raise a NotFound error if the project doesn't exist
333+
self._openshift_get_project(project_id)
334+
api = self.get_resource_api(API_CORE, "ResourceQuota")
335+
res = clean_openshift_metadata(api.get(namespace=project_id).to_dict())
336+
337+
return res["items"]
338+
339+
def _wait_for_quota_to_settle(self, project_id, resource_quota):
340+
"""Wait for quota on resourcequotas to settle.
341+
342+
When creating a new resourcequota that sets a quota on resourcequota objects, we need to
343+
wait for OpenShift to calculate the quota usage before we attempt to create any new
344+
resourcequota objects.
345+
"""
346+
347+
if "resourcequotas" in resource_quota["spec"]["hard"]:
348+
logger.info("waiting for resourcequota quota")
349+
350+
api = self.get_resource_api(API_CORE, "ResourceQuota")
351+
while True:
352+
resp = clean_openshift_metadata(
353+
api.get(
354+
namespace=project_id, name=resource_quota["metadata"]["name"]
355+
).to_dict()
356+
)
357+
if "resourcequotas" in resp["status"].get("used", {}):
358+
break
359+
time.sleep(0.1)
360+
361+
def _openshift_create_resourcequota(self, project_id, quota_def):
362+
api = self.get_resource_api(API_CORE, "ResourceQuota")
363+
res = api.create(namespace=project_id, body=quota_def).to_dict()
364+
self._wait_for_quota_to_settle(project_id, res)
365+
366+
def _openshift_delete_resourcequota(self, project_id, resourcequota_name):
367+
"""In an openshift namespace {project_id) delete a specified resourcequota"""
368+
api = self.get_resource_api(API_CORE, "ResourceQuota")
369+
return api.delete(namespace=project_id, name=resourcequota_name).to_dict()
370+

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

Lines changed: 28 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -126,17 +126,16 @@ def test_new_allocation_quota(self):
126126
self.assertEqual(allocation.get_attribute(attributes.QUOTA_REQUESTS_GPU), 2 * 0)
127127
self.assertEqual(allocation.get_attribute(attributes.QUOTA_PVC), 2 * 2)
128128

129-
quota = allocator.get_quota(project_id)['Quota']
130-
quota = {k: v for k, v in quota.items() if v is not None}
129+
quota = allocator.get_quota(project_id)
131130
# The return value will update to the most relevant unit, so
132131
# 2000m cores becomes 2 and 8192Mi becomes 8Gi
133132
self.assertEqual(quota, {
134-
":limits.cpu": "2",
135-
":limits.memory": "8Gi",
136-
":limits.ephemeral-storage": "10Gi",
137-
":requests.storage": "40Gi",
138-
":requests.nvidia.com/gpu": "0",
139-
":persistentvolumeclaims": "4",
133+
"limits.cpu": "2",
134+
"limits.memory": "8Gi",
135+
"limits.ephemeral-storage": "10Gi",
136+
"requests.storage": "40Gi",
137+
"requests.nvidia.com/gpu": "0",
138+
"persistentvolumeclaims": "4",
140139
})
141140

142141
# change a bunch of attributes
@@ -157,16 +156,16 @@ def test_new_allocation_quota(self):
157156
# This call should update the openshift quota to match the current attributes
158157
call_command('validate_allocations', apply=True)
159158

160-
quota = allocator.get_quota(project_id)['Quota']
159+
quota = allocator.get_quota(project_id)
161160
quota = {k: v for k, v in quota.items() if v is not None}
162161

163162
self.assertEqual(quota, {
164-
":limits.cpu": "6",
165-
":limits.memory": "8Gi",
166-
":limits.ephemeral-storage": "50Gi",
167-
":requests.storage": "100Gi",
168-
":requests.nvidia.com/gpu": "1",
169-
":persistentvolumeclaims": "10",
163+
"limits.cpu": "6",
164+
"limits.memory": "8Gi",
165+
"limits.ephemeral-storage": "50Gi",
166+
"requests.storage": "100Gi",
167+
"requests.nvidia.com/gpu": "1",
168+
"persistentvolumeclaims": "10",
170169
})
171170

172171
def test_reactivate_allocation(self):
@@ -183,19 +182,17 @@ def test_reactivate_allocation(self):
183182

184183
self.assertEqual(allocation.get_attribute(attributes.QUOTA_LIMITS_CPU), 2)
185184

186-
quota = allocator.get_quota(project_id)['Quota']
185+
quota = allocator.get_quota(project_id)
187186

188-
# https://github.com/CCI-MOC/openshift-acct-mgt
189-
quota = {k: v for k, v in quota.items() if v is not None}
190187
# The return value will update to the most relevant unit, so
191188
# 2000m cores becomes 2 and 8192Mi becomes 8Gi
192189
self.assertEqual(quota, {
193-
":limits.cpu": "2",
194-
":limits.memory": "8Gi",
195-
":limits.ephemeral-storage": "10Gi",
196-
":requests.storage": "40Gi",
197-
":requests.nvidia.com/gpu": "0",
198-
":persistentvolumeclaims": "4",
190+
"limits.cpu": "2",
191+
"limits.memory": "8Gi",
192+
"limits.ephemeral-storage": "10Gi",
193+
"requests.storage": "40Gi",
194+
"requests.nvidia.com/gpu": "0",
195+
"persistentvolumeclaims": "4",
199196
})
200197

201198
# Simulate an attribute change request and subsequent approval which
@@ -204,17 +201,16 @@ def test_reactivate_allocation(self):
204201
tasks.activate_allocation(allocation.pk)
205202
allocation.refresh_from_db()
206203

207-
quota = allocator.get_quota(project_id)['Quota']
208-
quota = {k: v for k, v in quota.items() if v is not None}
204+
quota = allocator.get_quota(project_id)
209205
# The return value will update to the most relevant unit, so
210206
# 3000m cores becomes 3 and 8192Mi becomes 8Gi
211207
self.assertEqual(quota, {
212-
":limits.cpu": "3",
213-
":limits.memory": "8Gi",
214-
":limits.ephemeral-storage": "10Gi",
215-
":requests.storage": "40Gi",
216-
":requests.nvidia.com/gpu": "0",
217-
":persistentvolumeclaims": "4",
208+
"limits.cpu": "3",
209+
"limits.memory": "8Gi",
210+
"limits.ephemeral-storage": "10Gi",
211+
"requests.storage": "40Gi",
212+
"requests.nvidia.com/gpu": "0",
213+
"persistentvolumeclaims": "4",
218214
})
219215

220216
allocator._get_role(user.username, project_id)
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
from unittest import mock
2+
3+
from coldfront_plugin_cloud.tests import base
4+
from coldfront_plugin_cloud.openshift import OpenShiftResourceAllocator
5+
6+
7+
class TestOpenshiftQuota(base.TestBase):
8+
def setUp(self) -> None:
9+
mock_resource = mock.Mock()
10+
mock_allocation = mock.Mock()
11+
self.allocator = OpenShiftResourceAllocator(mock_resource, mock_allocation)
12+
self.allocator.id_provider = "fake_idp"
13+
self.allocator.k8_client = mock.Mock()
14+
15+
@mock.patch("coldfront_plugin_cloud.openshift.OpenShiftResourceAllocator._openshift_get_project", mock.Mock())
16+
def test_get_resourcequotas(self):
17+
fake_quota = mock.Mock(spec=["to_dict"])
18+
fake_quota.to_dict.return_value = {"items": []}
19+
self.allocator.k8_client.resources.get.return_value.get.return_value = fake_quota
20+
res = self.allocator._openshift_get_resourcequotas("fake-project")
21+
self.allocator.k8_client.resources.get.return_value.get.assert_called()
22+
assert res == []
23+
24+
25+
def test_delete_quota(self):
26+
fake_quota = mock.Mock(spec=["to_dict"])
27+
fake_quota.to_dict.return_value = {}
28+
self.allocator.k8_client.resources.get.return_value.delete.return_value = fake_quota
29+
res = self.allocator._openshift_delete_resourcequota("test-project", "test-quota")
30+
self.allocator.k8_client.resources.get.return_value.delete.assert_called()
31+
assert res == {}
32+
33+
34+
@mock.patch("coldfront_plugin_cloud.openshift.OpenShiftResourceAllocator._openshift_get_resourcequotas")
35+
def test_delete_moc_quota(self, fake_get_resourcequotas):
36+
fake_get_resourcequotas.return_value = [{"metadata": {"name": "fake-quota"}}]
37+
self.allocator.delete_moc_quotas("test-project")
38+
self.allocator.k8_client.resources.get.return_value.delete.assert_any_call(
39+
namespace="test-project", name="fake-quota"
40+
)
41+
42+
43+
@mock.patch("coldfront_plugin_cloud.openshift.OpenShiftResourceAllocator._wait_for_quota_to_settle")
44+
def test_create_shift_quotas(self, fake_wait_quota):
45+
quotadefs = {
46+
"metadata": {"name": "fake-project-project"},
47+
"spec": {"hard": {"configmaps": "1", "cpu": "1", "resourcequotas": "1"}},
48+
}
49+
50+
self.allocator.k8_client.resources.get.return_value.create.return_value = mock.Mock()
51+
52+
self.allocator._openshift_create_resourcequota("fake-project", quotadefs)
53+
54+
self.allocator.k8_client.resources.get.return_value.create.assert_called_with(
55+
namespace="fake-project",
56+
body={
57+
"metadata": {"name": "fake-project-project"},
58+
"spec": {"hard": {"configmaps": "1", "cpu": "1", "resourcequotas": "1"}},
59+
},
60+
)
61+
62+
fake_wait_quota.assert_called()
63+
64+
65+
def test_wait_for_quota_to_settle(self):
66+
fake_quota = mock.Mock(spec=["to_dict"])
67+
fake_quota.to_dict.return_value = {
68+
"metadata": {"name": "fake-quota"},
69+
"spec": {"hard": {"resourcequotas": "1"}},
70+
"status": {"used": {"resourcequotas": "1"}},
71+
}
72+
self.allocator.k8_client.resources.get.return_value.get.return_value = fake_quota
73+
74+
self.allocator._wait_for_quota_to_settle("fake-project", fake_quota.to_dict())
75+
76+
self.allocator.k8_client.resources.get.return_value.get.assert_called_with(
77+
namespace="fake-project",
78+
name="fake-quota",
79+
)
80+
81+
@mock.patch("coldfront_plugin_cloud.openshift.OpenShiftResourceAllocator._openshift_get_resourcequotas")
82+
def test_get_moc_quota(self, fake_get_quota):
83+
expected_quota = {
84+
"services": "2",
85+
"configmaps": None,
86+
"cpu": "1000",
87+
}
88+
fake_get_quota.return_value = [
89+
{
90+
"spec": {
91+
"hard": expected_quota
92+
},
93+
}
94+
]
95+
res = self.allocator.get_quota("fake-project")
96+
self.assertEqual(res, expected_quota)
97+
98+
99+

0 commit comments

Comments
 (0)