Skip to content

Commit 17922fa

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 pertaining to the `quota.json`[1] and `limits.json`[2]. [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
1 parent 6771783 commit 17922fa

File tree

3 files changed

+206
-16
lines changed

3 files changed

+206
-16
lines changed

src/coldfront_plugin_cloud/attributes.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ class CloudAllocationAttribute:
1818
is_changeable: bool = True
1919

2020

21-
RESOURCE_API_URL = 'OpenShift API Endpoint URL'
2221
RESOURCE_AUTH_URL = 'Identity Endpoint URL'
22+
RESOURCE_API_URL = 'OpenShift API Endpoint URL'
2323
RESOURCE_IDENTITY_NAME = 'OpenShift Identity Provider Name'
2424
RESOURCE_ROLE = 'Role for User in Project'
2525

@@ -33,8 +33,8 @@ class CloudAllocationAttribute:
3333
RESOURCE_EULA_URL = "EULA URL"
3434

3535
RESOURCE_ATTRIBUTES = [
36-
CloudResourceAttribute(name=RESOURCE_API_URL),
3736
CloudResourceAttribute(name=RESOURCE_AUTH_URL),
37+
CloudResourceAttribute(name=RESOURCE_API_URL),
3838
CloudResourceAttribute(name=RESOURCE_IDENTITY_NAME),
3939
CloudResourceAttribute(name=RESOURCE_FEDERATION_PROTOCOL),
4040
CloudResourceAttribute(name=RESOURCE_IDP),

src/coldfront_plugin_cloud/openshift.py

Lines changed: 103 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,64 @@ 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")
176+
177+
def _get_moc_quota_from_resourcequotas(self, project_id):
178+
"""This returns a dictionary suitable for merging in with the
179+
specification from Adjutant/ColdFront"""
180+
resourcequotas = self._openshift_get_resourcequotas(project_id)
181+
moc_quota = {}
182+
for rq in resourcequotas:
183+
name, spec = rq["metadata"]["name"], rq["spec"]
184+
logger.info(f"processing resourcequota: {project_id}:{name}")
185+
scope_list = spec.get("scopes", [""])
186+
for quota_name, quota_value in spec.get("hard", {}).items():
187+
for scope_item in scope_list:
188+
moc_quota_name = f"{scope_item}:{quota_name}"
189+
moc_quota.setdefault(moc_quota_name, quota_value)
190+
return moc_quota
158191

159192
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)
193+
quota_from_project = self._get_moc_quota_from_resourcequotas(project_id)
194+
195+
quota = {}
196+
for quota_name, quota_value in quota_from_project.items():
197+
if quota_value:
198+
quota[quota_name] = quota_value
199+
200+
quota_object = {
201+
"Version": "0.9",
202+
"Kind": "MocQuota",
203+
"ProjectName": project_id,
204+
"Quota": quota,
205+
}
206+
return quota_object
163207

164208
def create_project_defaults(self, project_id):
165209
pass
@@ -297,3 +341,48 @@ def _openshift_useridentitymapping_exists(self, user_name, id_user):
297341
for identity in user.get("identities", [])
298342
)
299343

344+
def _openshift_get_project(self, project_name):
345+
api = self.get_resource_api(API_PROJECT, "Project")
346+
return clean_openshift_metadata(api.get(name=project_name).to_dict())
347+
348+
def _openshift_get_resourcequotas(self, project_id):
349+
"""Returns a list of all of the resourcequota objects"""
350+
# Raise a NotFound error if the project doesn't exist
351+
self._openshift_get_project(project_id)
352+
api = self.get_resource_api(API_CORE, "ResourceQuota")
353+
res = clean_openshift_metadata(api.get(namespace=project_id).to_dict())
354+
355+
return res["items"]
356+
357+
def _wait_for_quota_to_settle(self, project_id, resource_quota):
358+
"""Wait for quota on resourcequotas to settle.
359+
360+
When creating a new resourcequota that sets a quota on resourcequota objects, we need to
361+
wait for OpenShift to calculate the quota usage before we attempt to create any new
362+
resourcequota objects.
363+
"""
364+
365+
if "resourcequotas" in resource_quota["spec"]["hard"]:
366+
logger.info("waiting for resourcequota quota")
367+
368+
api = self.get_resource_api(API_CORE, "ResourceQuota")
369+
while True:
370+
resp = clean_openshift_metadata(
371+
api.get(
372+
namespace=project_id, name=resource_quota["metadata"]["name"]
373+
).to_dict()
374+
)
375+
if "resourcequotas" in resp["status"].get("used", {}):
376+
break
377+
time.sleep(0.1)
378+
379+
def _openshift_create_resourcequota(self, project_id, quota_def):
380+
api = self.get_resource_api(API_CORE, "ResourceQuota")
381+
res = api.create(namespace=project_id, body=quota_def).to_dict()
382+
self._wait_for_quota_to_settle(project_id, res)
383+
384+
def _openshift_delete_resourcequota(self, project_id, resourcequota_name):
385+
"""In an openshift namespace {project_id) delete a specified resourcequota"""
386+
api = self.get_resource_api(API_CORE, "ResourceQuota")
387+
return api.delete(namespace=project_id, name=resourcequota_name).to_dict()
388+
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
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._get_moc_quota_from_resourcequotas")
82+
def test_get_moc_quota(self, fake_get_quota):
83+
fake_get_quota.return_value = {
84+
":services": {"value": "2"},
85+
":configmaps": {"value": None},
86+
":cpu": {"value": "1000"},
87+
}
88+
res = self.allocator.get_quota("fake-project")
89+
assert res == {
90+
"Version": "0.9",
91+
"Kind": "MocQuota",
92+
"ProjectName": "fake-project",
93+
"Quota": {
94+
":services": {"value": "2"},
95+
":configmaps": {"value": None},
96+
":cpu": {"value": "1000"},
97+
},
98+
}
99+
100+
101+

0 commit comments

Comments
 (0)