Skip to content

Commit 14d3af0

Browse files
authored
Show Coral credits quotas in API view (#468)
1 parent 887c6b1 commit 14d3af0

File tree

10 files changed

+196
-7
lines changed

10 files changed

+196
-7
lines changed

api/azimuth/provider/dto.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,17 @@ class Credential:
5151
data: dict
5252

5353

54+
class QuotaType(enum.Enum):
55+
"""
56+
Enum representing the possible quota types.
57+
"""
58+
59+
COMPUTE = "COMPUTE"
60+
BLOCK_STORAGE = "BLOCK_STORAGE"
61+
CORAL_CREDITS = "CORAL_CREDITS"
62+
NETWORK = "NETWORK"
63+
64+
5465
@dataclass(frozen=True)
5566
class Quota:
5667
"""
@@ -67,6 +78,8 @@ class Quota:
6778
allocated: int
6879
#: The amount of the resource that has been used
6980
used: int
81+
#: Category of quota for filtering in UI
82+
quota_type: QuotaType
7083

7184

7285
@dataclass(frozen=True)

api/azimuth/provider/openstack/provider.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import base64
66
import dataclasses
7+
import datetime
78
import functools
89
import hashlib
910
import logging
@@ -14,7 +15,11 @@
1415
import certifi
1516
import dateutil.parser
1617
import rackit
18+
import requests
1719
import yaml
20+
from django.utils.timezone import make_aware
21+
22+
from azimuth.settings import cloud_settings
1823

1924
from .. import base, dto, errors # noqa: TID252
2025
from . import api
@@ -337,20 +342,23 @@ def quotas(self):
337342
None,
338343
compute_limits.total_cores,
339344
compute_limits.total_cores_used,
345+
dto.QuotaType.COMPUTE,
340346
),
341347
dto.Quota(
342348
"ram",
343349
"RAM",
344350
"MB",
345351
compute_limits.total_ram,
346352
compute_limits.total_ram_used,
353+
dto.QuotaType.COMPUTE,
347354
),
348355
dto.Quota(
349356
"machines",
350357
"Machines",
351358
None,
352359
compute_limits.instances,
353360
compute_limits.instances_used,
361+
dto.QuotaType.COMPUTE,
354362
),
355363
]
356364
# Get the floating ip quota
@@ -363,8 +371,15 @@ def quotas(self):
363371
network_quotas.floatingip,
364372
# Just get the length of the list of IPs
365373
len(list(self._connection.network.floatingips.all())),
374+
dto.QuotaType.NETWORK,
366375
)
367376
)
377+
# Get coral credits if available
378+
if not (
379+
cloud_settings.CORAL_CREDITS.CORAL_URI is None
380+
or cloud_settings.CORAL_CREDITS.TOKEN is None
381+
):
382+
quotas.extend(self._get_coral_quotas())
368383
# The volume service is optional
369384
# In the case where the service is not enabled, just don't add the quotas
370385
try:
@@ -377,20 +392,136 @@ def quotas(self):
377392
"GB",
378393
volume_limits.total_volume_gigabytes,
379394
volume_limits.total_gigabytes_used,
395+
dto.QuotaType.BLOCK_STORAGE,
380396
),
381397
dto.Quota(
382398
"volumes",
383399
"Volumes",
384400
None,
385401
volume_limits.volumes,
386402
volume_limits.volumes_used,
403+
dto.QuotaType.BLOCK_STORAGE,
387404
),
388405
]
389406
)
390407
except api.ServiceNotSupported:
391408
pass
392409
return quotas
393410

411+
def _coral_quotas_from_allocation(self, allocation, headers):
412+
quotas = []
413+
414+
human_readable_names = {
415+
"MEMORY_MB": "RAM (MB)",
416+
"DISK_GB": "Root disk (GB)",
417+
}
418+
419+
# Add quota for time until allocation expiry
420+
current_time = make_aware(datetime.datetime.now())
421+
target_tz = current_time.tzinfo
422+
start_time = parse_time_and_correct_tz(allocation["start"], target_tz)
423+
end_time = parse_time_and_correct_tz(allocation["end"], target_tz)
424+
425+
allocated_duration = (end_time - start_time).total_seconds() / 3600
426+
used_duration = (current_time - start_time).total_seconds() / 3600
427+
428+
quotas.append(
429+
dto.Quota(
430+
"expiry",
431+
"Allocated time used (hours)",
432+
"hours",
433+
int(allocated_duration),
434+
int(used_duration),
435+
dto.QuotaType.CORAL_CREDITS,
436+
)
437+
)
438+
439+
# Add quotas for Coral resource quotas
440+
active_allocation_id = allocation["id"]
441+
442+
allocation_resources = requests.get(
443+
cloud_settings.CORAL_CREDITS.CORAL_URI
444+
+ "/allocation/"
445+
+ str(active_allocation_id)
446+
+ "/resources",
447+
headers=headers,
448+
).json()
449+
450+
if len(allocation_resources) == 0:
451+
self._log("Allocated resources found in allocation", level=logging.WARN)
452+
return []
453+
454+
for resource in allocation_resources:
455+
resource_name = resource["resource_class"]["name"]
456+
quotas.append(
457+
dto.Quota(
458+
resource_name,
459+
human_readable_names.get(resource_name, resource_name) + " hours",
460+
"resource hours",
461+
resource["allocated_resource_hours"],
462+
resource["allocated_resource_hours"] - resource["resource_hours"],
463+
dto.QuotaType.CORAL_CREDITS,
464+
)
465+
)
466+
return quotas
467+
468+
def _get_coral_quotas(self):
469+
headers = {"Authorization": "Bearer " + cloud_settings.CORAL_CREDITS.TOKEN}
470+
accounts = requests.get(
471+
cloud_settings.CORAL_CREDITS.CORAL_URI + "/resource_provider_account",
472+
headers=headers,
473+
).json()
474+
475+
tenancy_account_list = list(
476+
filter(
477+
lambda a: a["project_id"].replace("-", "") == self._tenancy.id, accounts
478+
)
479+
)
480+
if len(tenancy_account_list) != 1:
481+
self._log(
482+
(
483+
"There should be exactly one resource provider account associated "
484+
"with the tenancy, there are currently %s"
485+
),
486+
len(tenancy_account_list),
487+
level=logging.WARN,
488+
)
489+
return []
490+
tenancy_account = tenancy_account_list[0]["account"]
491+
all_allocations = requests.get(
492+
cloud_settings.CORAL_CREDITS.CORAL_URI + "/allocation", headers=headers
493+
).json()
494+
account_allocations = filter(
495+
lambda a: a["account"] == tenancy_account, all_allocations
496+
)
497+
498+
current_time = make_aware(datetime.datetime.now())
499+
target_tz = current_time.tzinfo
500+
501+
active_allocation_list = list(
502+
filter(
503+
lambda a: parse_time_and_correct_tz(a["start"], target_tz)
504+
< current_time
505+
and current_time < parse_time_and_correct_tz(a["end"], target_tz),
506+
account_allocations,
507+
)
508+
)
509+
510+
if len(active_allocation_list) == 1:
511+
return self._coral_quotas_from_allocation(
512+
active_allocation_list[0], headers
513+
)
514+
else:
515+
self._log(
516+
(
517+
"There should be exactly one active allocation associated "
518+
"with the tenancy, there are currently %s"
519+
),
520+
len(active_allocation_list),
521+
level=logging.WARN,
522+
)
523+
return []
524+
394525
def _from_api_image(self, api_image):
395526
"""
396527
Converts an OpenStack API image object into a :py:class:`.dto.Image`.
@@ -1504,3 +1635,7 @@ def close(self):
15041635
"""
15051636
# Make sure the underlying api connection is closed
15061637
self._connection.close()
1638+
1639+
1640+
def parse_time_and_correct_tz(time_str, tz):
1641+
return datetime.datetime.strptime(time_str, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=tz)

api/azimuth/serializers.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,8 @@ def to_representation(self, obj):
251251
return result
252252

253253

254-
QuotaSerializer = make_dto_serializer(dto.Quota)
254+
class QuotaSerializer(make_dto_serializer(dto.Quota, exclude=["quota_type"])):
255+
quota_type = serializers.ReadOnlyField(source="quota_type.name")
255256

256257

257258
class ImageRefSerializer(RefSerializer):

api/azimuth/settings.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,11 @@ class SchedulingSettings(SettingsObject):
219219
MAX_PLATFORM_DURATION_HOURS = Setting(default=None)
220220

221221

222+
class CoralCreditsSetting(SettingsObject):
223+
TOKEN = Setting(default=None)
224+
CORAL_URI = Setting(default=None)
225+
226+
222227
class AzimuthSettings(SettingsObject):
223228
"""
224229
Settings object for the ``AZIMUTH`` setting.
@@ -298,6 +303,8 @@ class AzimuthSettings(SettingsObject):
298303
#: Configuration for advanced scheduling
299304
SCHEDULING = NestedSetting(SchedulingSettings)
300305

306+
CORAL_CREDITS = NestedSetting(CoralCreditsSetting)
307+
301308
#: URL for documentation
302309
DOCUMENTATION_URL = Setting(
303310
default="https://azimuth-cloud.github.io/azimuth-user-docs/"
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{{- with .Values.settings.coralCredits }}
2+
AZIMUTH:
3+
CORAL_CREDITS:
4+
CORAL_URI: {{ quote .uri }}
5+
{{- with .tokenSecretRef }}
6+
TOKEN: {{ index (lookup "v1" "Secret" .namespace .name).data .key | b64dec }}
7+
{{- end }}
8+
{{- end }}

chart/templates/api/settings.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,5 @@ data:
3535
{{- tpl (.Files.Get "files/api/settings/11-scheduling.yaml") . | b64enc | nindent 4 }}
3636
12-apps-provider.yaml: |
3737
{{- tpl (.Files.Get "files/api/settings/12-apps-provider.yaml") . | b64enc | nindent 4 }}
38+
13-coral-credits.yaml: |
39+
{{- tpl (.Files.Get "files/api/settings/13-coral-credits.yaml") . | b64enc | nindent 4 }}

chart/tests/__snapshot__/snapshot_test.yaml.snap

100644100755
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ templated manifests should match snapshot:
158158
template:
159159
metadata:
160160
annotations:
161-
azimuth.stackhpc.com/settings-checksum: 4456f249ad2b10af8275e59c37bb0435e01dcc236bdb926b6ebc3c19ba8eeea6
161+
azimuth.stackhpc.com/settings-checksum: ba9764bba470b2cacf65a4646a795dd5a62707a4879baac5749a8884af5dc0cd
162162
azimuth.stackhpc.com/theme-checksum: ec0f36322392deee39d80b7f77ecd634df60358857af9dc208077860c4e174ab
163163
kubectl.kubernetes.io/default-container: api
164164
labels:
@@ -273,6 +273,8 @@ templated manifests should match snapshot:
273273
QVpJTVVUSDoKICBTQ0hFRFVMSU5HOgogICAgRU5BQkxFRDogZmFsc2UK
274274
12-apps-provider.yaml: |
275275
Cg==
276+
13-coral-credits.yaml: |
277+
Cg==
276278
kind: Secret
277279
metadata:
278280
labels:

chart/values.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,12 @@ settings:
182182
# # and "ephemeral_disk" for the current flavor
183183
# description: >-
184184
# {{ cpus }} CPUs, {{ ram }} RAM, {{ disk }} disk, {{ ephemeral_disk }} ephemeral disk
185+
coralCredits:
186+
# uri:
187+
# tokenSecretRef:
188+
# name:
189+
# namespace:
190+
# key:
185191

186192
# Configuration for authentication
187193
authentication:

ui/src/components/pages/tenancy/platforms/scheduling.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,21 @@ const ProjectedQuotaProgressBar = ({ quota }) => {
6262

6363

6464
const ProjectedQuotas = ({ quotas }) => {
65-
const sortedQuotas = sortBy(
65+
let sortedQuotas = sortBy(
6666
quotas,
6767
q => {
6868
// Use a tuple of (index, name) so we can support unknown quotas
6969
const index = quotaOrdering.findIndex(el => el === q.resource);
7070
return [index >= 0 ? index : quotaOrdering.length, q.resource];
7171
}
7272
);
73+
74+
// These components don't seem to get optional fields from the UI
75+
// to filter for Coral credits resources with so just showing known
76+
// quotas for now until we have a way to calculate projections for Coral
77+
// or otherwise unknown quotas
78+
sortedQuotas = sortedQuotas.filter((q) => quotaOrdering.includes(q.resource));
79+
7380
return sortedQuotas.map(
7481
quota => <ProjectedQuotaProgressBar
7582
key={quota.resource}

ui/src/components/pages/tenancy/quotas.js

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { sortBy, usePageTitle, formatSize } from '../../utils';
1616
import { ResourcePanel } from './resource-utils';
1717

1818

19-
const QuotaProgress = ({ quota: { label, units, allocated, used } }) => {
19+
const QuotaProgress = ({ quota: { label, units, allocated, used, quota_type }, addPrefix }) => {
2020
const percent = allocated > 0 ? (used * 100) / allocated : 0;
2121
const formatAmount = amount => (
2222
["MB", "GB"].includes(units) ?
@@ -29,10 +29,17 @@ const QuotaProgress = ({ quota: { label, units, allocated, used } }) => {
2929
`${formatAmount(used)} used`
3030
);
3131
const colour = (percent <= 60 ? '#5cb85c' : (percent <= 80 ? '#f0ad4e' : '#d9534f'));
32+
33+
let labelPrefix = ""
34+
if(addPrefix){
35+
labelPrefix = quota_type == "CORAL_CREDITS" ? "Credits: " : "Quota: ";
36+
}
37+
38+
const displayLabel = labelPrefix + label
3239
return (
3340
<Col className="quota-card-wrapper">
3441
<Card className="h-100">
35-
<Card.Header><strong>{label}</strong></Card.Header>
42+
<Card.Header><strong>{displayLabel}</strong></Card.Header>
3643
<Card.Body>
3744
<CircularProgressbar
3845
className={allocated < 0 ? "quota-no-limit" : undefined}
@@ -56,19 +63,20 @@ const quotaOrdering = ["machines", "volumes", "external_ips", "cpus", "ram", "st
5663

5764

5865
const Quotas = ({ resourceData }) => {
59-
const sortedQuotas = sortBy(
66+
let sortedQuotas = sortBy(
6067
Object.values(resourceData),
6168
q => {
6269
// Use a tuple of (index, name) so we can support unknown quotas
6370
const index = quotaOrdering.findIndex(el => el === q.resource);
6471
return [index >= 0 ? index : quotaOrdering.length, q.resource];
6572
}
6673
);
74+
const containsCoralQuotas = sortedQuotas.some(q => q.quota_type == "CORAL_CREDITS")
6775
return (
6876
// The volume service is optional, so quotas might not always be available for it
6977
<Row className="g-3 justify-content-center">
7078
{sortedQuotas.map(quota => (
71-
<QuotaProgress key={quota.resource} quota={quota} />)
79+
<QuotaProgress key={quota.resource} quota={quota} addPrefix={containsCoralQuotas}/>)
7280
)}
7381
</Row>
7482
);

0 commit comments

Comments
 (0)