diff --git a/api/azimuth/provider/dto.py b/api/azimuth/provider/dto.py index 130eda3b..11401194 100644 --- a/api/azimuth/provider/dto.py +++ b/api/azimuth/provider/dto.py @@ -51,6 +51,16 @@ class Credential: data: dict +class QuotaType(enum.Enum): + """ + Enum representing the possible quota types. + """ + + NOVA = "NOVA" + CINDER = "CINDER" + CORAL = "CORAL" + + @dataclass(frozen=True) class Quota: """ @@ -67,6 +77,8 @@ class Quota: allocated: int #: The amount of the resource that has been used used: int + #: Category of quota for filtering in UI + quota_type: QuotaType @dataclass(frozen=True) diff --git a/api/azimuth/provider/openstack/provider.py b/api/azimuth/provider/openstack/provider.py index 5b43bea5..15d2bf47 100644 --- a/api/azimuth/provider/openstack/provider.py +++ b/api/azimuth/provider/openstack/provider.py @@ -4,6 +4,7 @@ import base64 import dataclasses +import datetime import functools import hashlib import logging @@ -14,7 +15,11 @@ import certifi import dateutil.parser import rackit +import requests import yaml +from django.utils.timezone import make_aware + +from azimuth.settings import cloud_settings from .. import base, dto, errors # noqa: TID252 from . import api @@ -337,6 +342,7 @@ def quotas(self): None, compute_limits.total_cores, compute_limits.total_cores_used, + dto.QuotaType.NOVA, ), dto.Quota( "ram", @@ -344,6 +350,7 @@ def quotas(self): "MB", compute_limits.total_ram, compute_limits.total_ram_used, + dto.QuotaType.NOVA, ), dto.Quota( "machines", @@ -351,6 +358,7 @@ def quotas(self): None, compute_limits.instances, compute_limits.instances_used, + dto.QuotaType.NOVA, ), ] # Get the floating ip quota @@ -363,8 +371,15 @@ def quotas(self): network_quotas.floatingip, # Just get the length of the list of IPs len(list(self._connection.network.floatingips.all())), + dto.QuotaType.NOVA, ) ) + # Get coral credits if available + if not ( + cloud_settings.CORAL_CREDITS.CORAL_URI is None + or cloud_settings.CORAL_CREDITS.TOKEN is None + ): + quotas.extend(self._get_coral_quotas()) # The volume service is optional # In the case where the service is not enabled, just don't add the quotas try: @@ -377,6 +392,7 @@ def quotas(self): "GB", volume_limits.total_volume_gigabytes, volume_limits.total_gigabytes_used, + dto.QuotaType.CINDER, ), dto.Quota( "volumes", @@ -384,6 +400,7 @@ def quotas(self): None, volume_limits.volumes, volume_limits.volumes_used, + dto.QuotaType.CINDER, ), ] ) @@ -391,6 +408,120 @@ def quotas(self): pass return quotas + def _coral_quotas_from_allocation(self, allocation, headers): + quotas = [] + + human_readable_names = { + "MEMORY_MB": "RAM (MB)", + "DISK_GB": "Root disk (GB)", + } + + # Add quota for time until allocation expiry + current_time = make_aware(datetime.datetime.now()) + target_tz = current_time.tzinfo + start_time = parse_time_and_correct_tz(allocation["start"], target_tz) + end_time = parse_time_and_correct_tz(allocation["end"], target_tz) + + allocated_duration = (end_time - start_time).total_seconds() / 3600 + used_duration = (current_time - start_time).total_seconds() / 3600 + + quotas.append( + dto.Quota( + "expiry", + "Allocated time used (hours)", + "hours", + int(allocated_duration), + int(used_duration), + dto.QuotaType.CORAL, + ) + ) + + # Add quotas for Coral resource quotas + active_allocation_id = allocation["id"] + + allocation_resources = requests.get( + cloud_settings.CORAL_CREDITS.CORAL_URI + + "/allocation/" + + str(active_allocation_id) + + "/resources", + headers=headers, + ).json() + + if len(allocation_resources) == 0: + self._log("Allocated resources found in allocation", level=logging.WARN) + return [] + + for resource in allocation_resources: + resource_name = resource["resource_class"]["name"] + quotas.append( + dto.Quota( + resource_name, + human_readable_names.get(resource_name, resource_name) + " hours", + "resource hours", + resource["allocated_resource_hours"], + resource["allocated_resource_hours"] - resource["resource_hours"], + dto.QuotaType.CORAL, + ) + ) + return quotas + + def _get_coral_quotas(self): + headers = {"Authorization": "Bearer " + cloud_settings.CORAL_CREDITS.TOKEN} + accounts = requests.get( + cloud_settings.CORAL_CREDITS.CORAL_URI + "/resource_provider_account", + headers=headers, + ).json() + + tenancy_account_list = list( + filter( + lambda a: a["project_id"].replace("-", "") == self._tenancy.id, accounts + ) + ) + if len(tenancy_account_list) != 1: + self._log( + ( + "There should be exactly one resource provider account associated " + "with the tenancy, there are currently %s" + ), + len(tenancy_account_list), + level=logging.WARN, + ) + return [] + tenancy_account = tenancy_account_list[0]["account"] + all_allocations = requests.get( + cloud_settings.CORAL_CREDITS.CORAL_URI + "/allocation", headers=headers + ).json() + account_allocations = filter( + lambda a: a["account"] == tenancy_account, all_allocations + ) + + current_time = make_aware(datetime.datetime.now()) + target_tz = current_time.tzinfo + + active_allocation_list = list( + filter( + lambda a: parse_time_and_correct_tz(a["start"], target_tz) + < current_time + and current_time < parse_time_and_correct_tz(a["end"], target_tz), + account_allocations, + ) + ) + + if len(active_allocation_list) == 1: + return self._coral_quotas_from_allocation( + active_allocation_list[0], headers + ) + else: + self._log( + ( + "There should be exactly one active allocation associated " + "with the tenancy, there are currently %s" + ), + len(active_allocation_list), + level=logging.WARN, + ) + return [] + def _from_api_image(self, api_image): """ Converts an OpenStack API image object into a :py:class:`.dto.Image`. @@ -1504,3 +1635,7 @@ def close(self): """ # Make sure the underlying api connection is closed self._connection.close() + + +def parse_time_and_correct_tz(time_str, tz): + return datetime.datetime.strptime(time_str, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=tz) diff --git a/api/azimuth/serializers.py b/api/azimuth/serializers.py index 99ce74f7..46e7e611 100644 --- a/api/azimuth/serializers.py +++ b/api/azimuth/serializers.py @@ -251,7 +251,8 @@ def to_representation(self, obj): return result -QuotaSerializer = make_dto_serializer(dto.Quota) +class QuotaSerializer(make_dto_serializer(dto.Quota, exclude=["quota_type"])): + quota_type = serializers.ReadOnlyField(source="quota_type.name") class ImageRefSerializer(RefSerializer): diff --git a/api/azimuth/settings.py b/api/azimuth/settings.py index a2787e71..915d0fb6 100644 --- a/api/azimuth/settings.py +++ b/api/azimuth/settings.py @@ -219,6 +219,11 @@ class SchedulingSettings(SettingsObject): MAX_PLATFORM_DURATION_HOURS = Setting(default=None) +class CoralCreditsSetting(SettingsObject): + TOKEN = Setting(default=None) + CORAL_URI = Setting(default=None) + + class AzimuthSettings(SettingsObject): """ Settings object for the ``AZIMUTH`` setting. @@ -298,6 +303,8 @@ class AzimuthSettings(SettingsObject): #: Configuration for advanced scheduling SCHEDULING = NestedSetting(SchedulingSettings) + CORAL_CREDITS = NestedSetting(CoralCreditsSetting) + #: URL for documentation DOCUMENTATION_URL = Setting( default="https://azimuth-cloud.github.io/azimuth-user-docs/" diff --git a/chart/files/api/settings/13-coral-credits.yaml b/chart/files/api/settings/13-coral-credits.yaml new file mode 100644 index 00000000..b4cfff04 --- /dev/null +++ b/chart/files/api/settings/13-coral-credits.yaml @@ -0,0 +1,8 @@ +{{- with .Values.settings.coralCredits }} +AZIMUTH: + CORAL_CREDITS: + CORAL_URI: {{ quote .uri }} + {{- with .tokenSecretRef }} + TOKEN: {{ index (lookup "v1" "Secret" .namespace .name).data .key | b64dec }} + {{- end }} +{{- end }} diff --git a/chart/templates/api/settings.yaml b/chart/templates/api/settings.yaml index ebe2a587..2be8b42c 100644 --- a/chart/templates/api/settings.yaml +++ b/chart/templates/api/settings.yaml @@ -35,3 +35,5 @@ data: {{- tpl (.Files.Get "files/api/settings/11-scheduling.yaml") . | b64enc | nindent 4 }} 12-apps-provider.yaml: | {{- tpl (.Files.Get "files/api/settings/12-apps-provider.yaml") . | b64enc | nindent 4 }} + 13-coral-credits.yaml: | + {{- tpl (.Files.Get "files/api/settings/13-coral-credits.yaml") . | b64enc | nindent 4 }} diff --git a/chart/tests/__snapshot__/snapshot_test.yaml.snap b/chart/tests/__snapshot__/snapshot_test.yaml.snap old mode 100644 new mode 100755 index d5b68090..531e1675 --- a/chart/tests/__snapshot__/snapshot_test.yaml.snap +++ b/chart/tests/__snapshot__/snapshot_test.yaml.snap @@ -158,7 +158,7 @@ templated manifests should match snapshot: template: metadata: annotations: - azimuth.stackhpc.com/settings-checksum: 4456f249ad2b10af8275e59c37bb0435e01dcc236bdb926b6ebc3c19ba8eeea6 + azimuth.stackhpc.com/settings-checksum: ba9764bba470b2cacf65a4646a795dd5a62707a4879baac5749a8884af5dc0cd azimuth.stackhpc.com/theme-checksum: ec0f36322392deee39d80b7f77ecd634df60358857af9dc208077860c4e174ab kubectl.kubernetes.io/default-container: api labels: @@ -273,6 +273,8 @@ templated manifests should match snapshot: QVpJTVVUSDoKICBTQ0hFRFVMSU5HOgogICAgRU5BQkxFRDogZmFsc2UK 12-apps-provider.yaml: | Cg== + 13-coral-credits.yaml: | + Cg== kind: Secret metadata: labels: diff --git a/chart/values.yaml b/chart/values.yaml index 47a45a59..28286e64 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -182,6 +182,12 @@ settings: # # and "ephemeral_disk" for the current flavor # description: >- # {{ cpus }} CPUs, {{ ram }} RAM, {{ disk }} disk, {{ ephemeral_disk }} ephemeral disk + coralCredits: + # uri: + # tokenSecretRef: + # name: + # namespace: + # key: # Configuration for authentication authentication: diff --git a/ui/src/components/pages/tenancy/platforms/scheduling.js b/ui/src/components/pages/tenancy/platforms/scheduling.js index 1a6cbf65..009a6fa5 100644 --- a/ui/src/components/pages/tenancy/platforms/scheduling.js +++ b/ui/src/components/pages/tenancy/platforms/scheduling.js @@ -62,7 +62,7 @@ const ProjectedQuotaProgressBar = ({ quota }) => { const ProjectedQuotas = ({ quotas }) => { - const sortedQuotas = sortBy( + let sortedQuotas = sortBy( quotas, q => { // Use a tuple of (index, name) so we can support unknown quotas @@ -70,6 +70,13 @@ const ProjectedQuotas = ({ quotas }) => { return [index >= 0 ? index : quotaOrdering.length, q.resource]; } ); + + // These components don't seem to get optional fields from the UI + // to filter for Coral credits resources with so just showing known + // quotas for now until we have a way to calculate projections for Coral + // or otherwise unknown quotas + sortedQuotas = sortedQuotas.filter((q) => quotaOrdering.includes(q.resource)); + return sortedQuotas.map( quota => { +const QuotaProgress = ({ quota: { label, units, allocated, used, quota_type }, addPrefix }) => { const percent = allocated > 0 ? (used * 100) / allocated : 0; const formatAmount = amount => ( ["MB", "GB"].includes(units) ? @@ -29,10 +29,17 @@ const QuotaProgress = ({ quota: { label, units, allocated, used } }) => { `${formatAmount(used)} used` ); const colour = (percent <= 60 ? '#5cb85c' : (percent <= 80 ? '#f0ad4e' : '#d9534f')); + + let labelPrefix = "" + if(addPrefix){ + labelPrefix = quota_type == "CORAL" ? "Credits: " : "Quota: "; + } + + const displayLabel = labelPrefix + label return ( - {label} + {displayLabel} { - const sortedQuotas = sortBy( + let sortedQuotas = sortBy( Object.values(resourceData), q => { // Use a tuple of (index, name) so we can support unknown quotas @@ -64,11 +71,12 @@ const Quotas = ({ resourceData }) => { return [index >= 0 ? index : quotaOrdering.length, q.resource]; } ); + const containsCoralQuotas = sortedQuotas.some(q => q.quota_type == "CORAL") return ( // The volume service is optional, so quotas might not always be available for it {sortedQuotas.map(quota => ( - ) + ) )} );