diff --git a/src/coldfront_plugin_cloud/attributes.py b/src/coldfront_plugin_cloud/attributes.py index 697d1920..6b74b23b 100644 --- a/src/coldfront_plugin_cloud/attributes.py +++ b/src/coldfront_plugin_cloud/attributes.py @@ -24,7 +24,7 @@ class CloudAllocationAttribute: RESOURCE_API_URL = "OpenShift API Endpoint URL" RESOURCE_IDENTITY_NAME = "OpenShift Identity Provider Name" RESOURCE_ROLE = "Role for User in Project" -RESOURCE_IBM_AVAILABLE = "IBM Spectrum Scale Storage Available" +RESOURCE_QUOTA_RESOURCES = "Available Quota Resources" RESOURCE_FEDERATION_PROTOCOL = "OpenStack Federation Protocol" RESOURCE_IDP = "OpenStack Identity Provider" @@ -44,7 +44,7 @@ class CloudAllocationAttribute: CloudResourceAttribute(name=RESOURCE_IDP), CloudResourceAttribute(name=RESOURCE_PROJECT_DOMAIN), CloudResourceAttribute(name=RESOURCE_ROLE), - CloudResourceAttribute(name=RESOURCE_IBM_AVAILABLE), + CloudResourceAttribute(name=RESOURCE_QUOTA_RESOURCES), CloudResourceAttribute(name=RESOURCE_USER_DOMAIN), CloudResourceAttribute(name=RESOURCE_EULA_URL), CloudResourceAttribute(name=RESOURCE_DEFAULT_PUBLIC_NETWORK), @@ -116,23 +116,5 @@ class CloudAllocationAttribute: ALLOCATION_QUOTA_ATTRIBUTES = [ - CloudAllocationAttribute(name=QUOTA_INSTANCES), - CloudAllocationAttribute(name=QUOTA_RAM), - CloudAllocationAttribute(name=QUOTA_VCPU), - CloudAllocationAttribute(name=QUOTA_VOLUMES), - CloudAllocationAttribute(name=QUOTA_VOLUMES_GB), - CloudAllocationAttribute(name=QUOTA_NETWORKS), - CloudAllocationAttribute(name=QUOTA_FLOATING_IPS), - CloudAllocationAttribute(name=QUOTA_OBJECT_GB), CloudAllocationAttribute(name=QUOTA_GPU), - CloudAllocationAttribute(name=QUOTA_LIMITS_CPU), - CloudAllocationAttribute(name=QUOTA_LIMITS_MEMORY), - CloudAllocationAttribute(name=QUOTA_LIMITS_EPHEMERAL_STORAGE_GB), - CloudAllocationAttribute(name=QUOTA_REQUESTS_NESE_STORAGE), - CloudAllocationAttribute(name=QUOTA_REQUESTS_IBM_STORAGE), - CloudAllocationAttribute(name=QUOTA_REQUESTS_GPU), - CloudAllocationAttribute(name=QUOTA_REQUESTS_VM_GPU_A100_SXM4), - CloudAllocationAttribute(name=QUOTA_REQUESTS_VM_GPU_V100), - CloudAllocationAttribute(name=QUOTA_REQUESTS_VM_GPU_H100), - CloudAllocationAttribute(name=QUOTA_PVC), ] diff --git a/src/coldfront_plugin_cloud/base.py b/src/coldfront_plugin_cloud/base.py index e5960d81..5d9a075a 100644 --- a/src/coldfront_plugin_cloud/base.py +++ b/src/coldfront_plugin_cloud/base.py @@ -1,11 +1,13 @@ import abc import functools +import json from typing import NamedTuple from coldfront.core.allocation import models as allocation_models from coldfront.core.resource import models as resource_models from coldfront_plugin_cloud import attributes +from coldfront_plugin_cloud.models.quota_models import QuotaSpecs class ResourceAllocator(abc.ABC): @@ -25,6 +27,14 @@ def __init__( self.resource = resource self.allocation = allocation + resource_quota_attr = resource_models.ResourceAttribute.objects.get( + resource=resource, + resource_attribute_type__name=attributes.RESOURCE_QUOTA_RESOURCES, + ) + self.resource_quotaspecs = QuotaSpecs.model_validate( + json.loads(resource_quota_attr.value) + ) + def get_or_create_federated_user(self, username): if not (user := self.get_federated_user(username)): user = self.create_federated_user(username) diff --git a/src/coldfront_plugin_cloud/esi.py b/src/coldfront_plugin_cloud/esi.py index 1b90374c..c815f804 100644 --- a/src/coldfront_plugin_cloud/esi.py +++ b/src/coldfront_plugin_cloud/esi.py @@ -1,23 +1,7 @@ -from coldfront_plugin_cloud import attributes from coldfront_plugin_cloud.openstack import OpenStackResourceAllocator class ESIResourceAllocator(OpenStackResourceAllocator): - QUOTA_KEY_MAPPING = { - "network": { - "keys": { - attributes.QUOTA_FLOATING_IPS: "floatingip", - attributes.QUOTA_NETWORKS: "network", - } - } - } - - QUOTA_KEY_MAPPING_ALL_KEYS = { - quota_key: quota_name - for k in QUOTA_KEY_MAPPING.values() - for quota_key, quota_name in k["keys"].items() - } - resource_type = "esi" def get_quota(self, project_id): diff --git a/src/coldfront_plugin_cloud/management/commands/add_openshift_resource.py b/src/coldfront_plugin_cloud/management/commands/add_openshift_resource.py index 128c00fc..042b856e 100644 --- a/src/coldfront_plugin_cloud/management/commands/add_openshift_resource.py +++ b/src/coldfront_plugin_cloud/management/commands/add_openshift_resource.py @@ -50,11 +50,6 @@ def add_arguments(self, parser): action="store_true", help="Indicates this is an OpenShift Virtualization resource (default: False)", ) - parser.add_argument( - "--ibm-storage-available", - action="store_true", - help="Indicates that Ibm Scale storage is available in this resource (default: False)", - ) def handle(self, *args, **options): self.validate_role(options["role"]) @@ -97,14 +92,6 @@ def handle(self, *args, **options): resource=openshift, value=options["role"], ) - - ResourceAttribute.objects.get_or_create( - resource_attribute_type=ResourceAttributeType.objects.get( - name=attributes.RESOURCE_IBM_AVAILABLE - ), - resource=openshift, - value="true" if options["ibm_storage_available"] else "false", - ) ResourceAttribute.objects.get_or_create( resource_attribute_type=ResourceAttributeType.objects.get( name=attributes.RESOURCE_CLUSTER_NAME diff --git a/src/coldfront_plugin_cloud/management/commands/add_quota_to_resource.py b/src/coldfront_plugin_cloud/management/commands/add_quota_to_resource.py new file mode 100644 index 00000000..a99cb74d --- /dev/null +++ b/src/coldfront_plugin_cloud/management/commands/add_quota_to_resource.py @@ -0,0 +1,109 @@ +import json +import logging + +from django.core.management.base import BaseCommand +from coldfront.core.resource.models import ( + Resource, + ResourceAttribute, + ResourceAttributeType, +) +from coldfront.core.allocation.models import AllocationAttributeType, AttributeType + +from coldfront_plugin_cloud import attributes +from coldfront_plugin_cloud.models.quota_models import QuotaSpecs, QuotaSpec + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + def add_arguments(self, parser): + parser.add_argument( + "--display-name", + type=str, + required=True, + help="The display name for the quota attribute to add to the resource type.", + ) + parser.add_argument( + "--resource-name", + type=str, + required=True, + help="The name of the resource to add the storage attribute to.", + ) + parser.add_argument( + "--quota-label", + type=str, + required=True, + help="The cluster-side label for the quota.", + ) + parser.add_argument( + "--multiplier", + type=int, + default=0, + help="Multiplier applied per SU quantity (int).", + ) + parser.add_argument( + "--static-quota", + type=int, + default=0, + help="Static quota added to every SU quantity (int).", + ) + parser.add_argument( + "--unit-suffix", + type=str, + default="", + help='Unit suffix to append to formatted quota values (e.g. "Gi").', + ) + parser.add_argument( + "--resource-type", + type=str, + default="", + help="Indicates which resource type this quota is. Type `storage` is relevant for storage billing", + ) + parser.add_argument( + "--invoice-name", + type=str, + default="", + help="Name of quota as it appears on invoice. Required if --resource-type is set to `storage`.", + ) + + def handle(self, *args, **options): + if options["resource_type"] == "storage" and not options["invoice_name"]: + logger.error( + "--invoice-name must be provided when resource type is `storage`." + ) + return + + resource_name = options["resource_name"] + display_name = options["display_name"] + new_quota_spec = QuotaSpec(**options) + new_quota_dict = {display_name: new_quota_spec.model_dump()} + QuotaSpecs.model_validate(new_quota_dict) + + resource = Resource.objects.get(name=resource_name) + available_quotas_attr, created = ResourceAttribute.objects.get_or_create( + resource=resource, + resource_attribute_type=ResourceAttributeType.objects.get( + name=attributes.RESOURCE_QUOTA_RESOURCES + ), + defaults={"value": json.dumps(new_quota_dict)}, + ) + + if not created: + available_quotas_dict = json.loads(available_quotas_attr.value) + available_quotas_dict.update(new_quota_dict) + QuotaSpecs.model_validate(available_quotas_dict) # Validate uniqueness + available_quotas_attr.value = json.dumps(available_quotas_dict) + available_quotas_attr.save() + + # Now create Allocation Attribute for this quota + AllocationAttributeType.objects.get_or_create( + name=display_name, + defaults={ + "attribute_type": AttributeType.objects.get(name="Int"), + "has_usage": False, + "is_private": False, + "is_changeable": True, + }, + ) + + logger.info("Added quota '%s' to resource '%s'.", display_name, resource_name) diff --git a/src/coldfront_plugin_cloud/management/commands/calculate_storage_gb_hours.py b/src/coldfront_plugin_cloud/management/commands/calculate_storage_gb_hours.py index f2f75fea..e96c4abf 100644 --- a/src/coldfront_plugin_cloud/management/commands/calculate_storage_gb_hours.py +++ b/src/coldfront_plugin_cloud/management/commands/calculate_storage_gb_hours.py @@ -1,4 +1,5 @@ import csv +import json from decimal import Decimal, ROUND_HALF_UP import dataclasses from datetime import datetime, timedelta, timezone @@ -7,6 +8,7 @@ from coldfront_plugin_cloud import attributes from coldfront_plugin_cloud import utils +from coldfront_plugin_cloud.models.quota_models import QuotaSpecs import boto3 from django.core.management.base import BaseCommand @@ -19,6 +21,7 @@ logger = logging.getLogger(__name__) _RATES = None +STORAGE_RESOURCE_TYPE_NAME = "storage" def get_rates(): @@ -210,6 +213,16 @@ def upload_to_s3(s3_endpoint, s3_bucket, file_location, invoice_month, end_time) def handle(self, *args, **options): generated_at = datetime.now(tz=timezone.utc).isoformat(timespec="seconds") + def get_storage_quotaspecs(allocation: Allocation): + """Get storage-related quota attributes for an allocation.""" + quotaspecs_dict = json.loads( + allocation.resources.first().get_attribute( + attributes.RESOURCE_QUOTA_RESOURCES + ) + ) + quotaspecs = QuotaSpecs.model_validate(quotaspecs_dict) + return quotaspecs.get_quotas_by_type(STORAGE_RESOURCE_TYPE_NAME) + def get_outages_for_service(cluster_name: str): """Get outages for a service from nerc-rates. @@ -316,12 +329,14 @@ def process_invoice_row(allocation, attrs, su_name, rate): ) logger.debug(f"Starting billing for allocation {allocation_str}.") - process_invoice_row( - allocation, - [attributes.QUOTA_VOLUMES_GB, attributes.QUOTA_OBJECT_GB], - "OpenStack Storage", - openstack_nese_storage_rate, - ) + quotaspecs = get_storage_quotaspecs(allocation) + for quota_name, quotaspec in quotaspecs.items(): + process_invoice_row( + allocation, + [quota_name], + quotaspec.invoice_name, + openstack_nese_storage_rate, + ) for allocation in openshift_allocations: allocation_str = ( diff --git a/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py b/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py index 3ae273e4..45b75d28 100644 --- a/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py +++ b/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py @@ -9,8 +9,8 @@ from coldfront_plugin_cloud import attributes from coldfront.core.utils.common import import_from_settings -from coldfront_plugin_cloud import usage_models -from coldfront_plugin_cloud.usage_models import UsageInfo, validate_date_str +from coldfront_plugin_cloud.models import usage_models +from coldfront_plugin_cloud.models.usage_models import UsageInfo, validate_date_str from coldfront_plugin_cloud import utils import boto3 diff --git a/src/coldfront_plugin_cloud/management/commands/register_default_quotas.py b/src/coldfront_plugin_cloud/management/commands/register_default_quotas.py new file mode 100644 index 00000000..05c13010 --- /dev/null +++ b/src/coldfront_plugin_cloud/management/commands/register_default_quotas.py @@ -0,0 +1,173 @@ +import logging + +from coldfront_plugin_cloud import attributes + +from django.core.management.base import BaseCommand +from django.core.management import call_command +from coldfront.core.resource.models import Resource + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +STORAGE_RESOURCE_TYPE_NAME = "storage" +OPENSHIFT_STORAGE_INVOICE_NAME = "OpenShift NESE Storage" +OPENSTACK_STORAGE_INVOICE_NAME = "OpenStack Storage" + + +class Command(BaseCommand): + help = """One time command to migrate quotas to each Openshift and Openstack resource""" + + def add_arguments(self, parser): + parser.add_argument( + "--apply", + action="store_true", + help="Apply the migration. Without this flag, only shows what would be done (dry run).", + ) + + def handle(self, *args, **options): + apply_migration = options.get("apply", False) + + if not apply_migration: + logger.info( + self.style.WARNING( + "DRY RUN MODE: No changes will be made. Use --apply to apply the migration." + ) + ) + + # Define quotas for each resource type + openshift_quotas = [ + { + "display_name": attributes.QUOTA_LIMITS_CPU, + "quota_label": "limits.cpu", + "multiplier": 1, + }, + { + "display_name": attributes.QUOTA_LIMITS_MEMORY, + "quota_label": "limits.memory", + "multiplier": 4096, + "unit_suffix": "Mi", + }, + { + "display_name": attributes.QUOTA_LIMITS_EPHEMERAL_STORAGE_GB, + "quota_label": "limits.ephemeral-storage", + "multiplier": 5, + "unit_suffix": "Gi", + "resource_type": STORAGE_RESOURCE_TYPE_NAME, + "invoice_name": OPENSHIFT_STORAGE_INVOICE_NAME, + }, + { + "display_name": attributes.QUOTA_PVC, + "quota_label": "persistentvolumeclaims", + "multiplier": 2, + }, + { + "display_name": attributes.QUOTA_REQUESTS_NESE_STORAGE, + "quota_label": "ocs-external-storagecluster-ceph-rbd.storageclass.storage.k8s.io/requests.storage", + "multiplier": 20, + "static_quota": 0, + "unit_suffix": "Gi", + "resource_type": STORAGE_RESOURCE_TYPE_NAME, + "invoice_name": OPENSHIFT_STORAGE_INVOICE_NAME, + }, + { + "display_name": attributes.QUOTA_REQUESTS_GPU, + "quota_label": "requests.nvidia.com/gpu", + "multiplier": 0, + }, + ] + + openstack_quotas = [ + { + "display_name": attributes.QUOTA_INSTANCES, + "quota_label": "compute.instances", + "multiplier": 1, + }, + { + "display_name": attributes.QUOTA_VCPU, + "quota_label": "compute.cores", + "multiplier": 1, + }, + { + "display_name": attributes.QUOTA_RAM, + "quota_label": "compute.ram", + "multiplier": 4096, + }, + { + "display_name": attributes.QUOTA_VOLUMES, + "quota_label": "volume.volumes", + "multiplier": 2, + }, + { + "display_name": attributes.QUOTA_VOLUMES_GB, + "quota_label": "volume.gigabytes", + "multiplier": 20, + "resource_type": STORAGE_RESOURCE_TYPE_NAME, + "invoice_name": OPENSTACK_STORAGE_INVOICE_NAME, + }, + { + "display_name": attributes.QUOTA_FLOATING_IPS, + "quota_label": "network.floatingip", + "multiplier": 0, + "static_quota": 2, + }, + { + "display_name": attributes.QUOTA_OBJECT_GB, + "quota_label": "object.x-account-meta-quota-bytes", + "multiplier": 1, + "resource_type": STORAGE_RESOURCE_TYPE_NAME, + "invoice_name": OPENSTACK_STORAGE_INVOICE_NAME, + }, + ] + + # Find OpenShift resources + try: + openshift_resources = Resource.objects.filter( + resource_type__name__in=["OpenShift", "OpenShift Virtualization"] + ) + except Resource.DoesNotExist: + openshift_resources = [] + + # Find OpenStack resources + try: + openstack_resources = Resource.objects.filter( + resource_type__name="OpenStack" + ) + except Resource.DoesNotExist: + openstack_resources = [] + + # Process OpenShift resources + for resource in openshift_resources: + logger.info(f"Processing OpenShift resource: {resource.name}") + if resource.get_attribute(attributes.RESOURCE_QUOTA_RESOURCES) is None: + for quota_info in openshift_quotas: + self._add_quota_to_resource(resource, quota_info, apply_migration) + else: + logger.info( + f"Resource {resource.name} already has quotas defined. Skipping." + ) + + # Process OpenStack resources + for resource in openstack_resources: + logger.info(f"Processing OpenStack resource: {resource.name}") + if resource.get_attribute(attributes.RESOURCE_QUOTA_RESOURCES) is None: + for quota_info in openstack_quotas: + self._add_quota_to_resource(resource, quota_info, apply_migration) + else: + logger.info( + f"Resource {resource.name} already has quotas defined. Skipping." + ) + + def _add_quota_to_resource(self, resource, quota_info, apply_migration): + """Add a quota to a resource""" + display_name = quota_info["display_name"] + logger.info(f"Adding {display_name} to {resource.name}") + + if apply_migration: + try: + call_command( + "add_quota_to_resource", resource_name=resource.name, **quota_info + ) + except Exception as e: + logger.error( + f"Error adding {display_name} to {resource.name}: {str(e)}" + ) diff --git a/src/coldfront_plugin_cloud/management/commands/remove_quota_from_resource.py b/src/coldfront_plugin_cloud/management/commands/remove_quota_from_resource.py new file mode 100644 index 00000000..e2166e33 --- /dev/null +++ b/src/coldfront_plugin_cloud/management/commands/remove_quota_from_resource.py @@ -0,0 +1,69 @@ +import json +import logging +from django.core.management.base import BaseCommand + +from coldfront.core.resource.models import ( + Resource, + ResourceAttribute, + ResourceAttributeType, +) +from coldfront_plugin_cloud import attributes +from coldfront_plugin_cloud.models.quota_models import QuotaSpecs + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Remove a quota from a resource's available resource quotas. This does not remove the quota's allocation attributes, so prior allocations will still see this quota. Use --apply to perform the change." + + def add_arguments(self, parser): + parser.add_argument( + "--resource-name", + type=str, + help="Name of the Resource to modify.", + ) + parser.add_argument( + "--display-name", + type=str, + help="Display name of the quota to remove.", + ) + parser.add_argument( + "--apply", + action="store_true", + dest="apply", + help="If set, apply the removal", + ) + + def handle(self, *args, **options): + resource_name = options["resource_name"] + display_name = options["display_name"] + apply_change = options["apply"] + + resource = Resource.objects.get(name=resource_name) + rat = ResourceAttributeType.objects.get( + name=attributes.RESOURCE_QUOTA_RESOURCES + ) + available_attr = ResourceAttribute.objects.get( + resource=resource, resource_attribute_type=rat + ) + + available_dict = json.loads(available_attr.value or "{}") + + if display_name not in available_dict: + logger.info( + "Display name '%s' not present on resource '%s'. Nothing to remove.", + display_name, + resource_name, + ) + return + + logger.info( + "Removing quota '%s' from resource '%s':", display_name, resource_name + ) + if not apply_change: + return + + del available_dict[display_name] + QuotaSpecs.model_validate(available_dict) + available_attr.value = json.dumps(available_dict) + available_attr.save() diff --git a/src/coldfront_plugin_cloud/management/commands/validate_allocations.py b/src/coldfront_plugin_cloud/management/commands/validate_allocations.py index 9eae7272..f30f6098 100644 --- a/src/coldfront_plugin_cloud/management/commands/validate_allocations.py +++ b/src/coldfront_plugin_cloud/management/commands/validate_allocations.py @@ -1,7 +1,6 @@ import logging from coldfront_plugin_cloud import attributes -from coldfront_plugin_cloud import openstack from coldfront_plugin_cloud import openshift from coldfront_plugin_cloud import utils from coldfront_plugin_cloud import tasks @@ -19,6 +18,7 @@ logger = logging.getLogger(__name__) STATES_TO_VALIDATE = ["Active", "Active (Needs Renewal)"] +OPENSTACK_OBJ_KEY = "x-account-meta-quota-bytes" class Command(BaseCommand): @@ -85,9 +85,10 @@ def sync_openshift_project_labels(project_id, allocator, apply): @staticmethod def set_default_quota_on_allocation(allocation, allocator, coldfront_attr): - uqm = tasks.UNIT_QUOTA_MULTIPLIERS[allocator.resource_type] - value = allocation.quantity * uqm.get(coldfront_attr, 0) - value += tasks.STATIC_QUOTA[allocator.resource_type].get(coldfront_attr, 0) + resource_quotaspecs = allocator.resource_quotaspecs + value = resource_quotaspecs.root[coldfront_attr].quota_by_su_quantity( + allocation.quantity + ) utils.set_attribute_on_allocation(allocation, coldfront_attr, value) return value @@ -143,12 +144,8 @@ def handle(self, *args, **options): project_id, allocation, allocator, options["apply"] ) - obj_key = openstack.OpenStackResourceAllocator.QUOTA_KEY_MAPPING["object"][ - "keys" - ][attributes.QUOTA_OBJECT_GB] - - for attr in tasks.get_expected_attributes(allocator): - key = allocator.QUOTA_KEY_MAPPING_ALL_KEYS.get(attr, None) + for attr, quotaspec in allocator.resource_quotaspecs.root.items(): + key = quotaspec.quota_label if not key: # Note(knikolla): Some attributes are only maintained # for bookkeeping purposes and do not have a @@ -157,10 +154,12 @@ def handle(self, *args, **options): expected_value = allocation.get_attribute(attr) current_value = quota.get(key, None) - if key == obj_key and expected_value <= 0: + if key == OPENSTACK_OBJ_KEY and expected_value <= 0: expected_obj_value = 1 current_value = int( - allocator.object(project_id).head_account().get(obj_key) + allocator.object(project_id) + .head_account() + .get(OPENSTACK_OBJ_KEY) ) if current_value != expected_obj_value: failed_validation = True @@ -251,11 +250,9 @@ def handle(self, *args, **options): project_id, allocator, options["apply"] ) - for attr in tasks.get_expected_attributes(allocator): - key_with_lambda = allocator.QUOTA_KEY_MAPPING.get(attr, None) - + for attr, quotaspec in allocator.resource_quotaspecs.root.items(): # This gives me just the plain key - key = list(key_with_lambda(1).keys())[0] + key = quotaspec.quota_label expected_value = allocation.get_attribute(attr) current_value = quota.get(key, None) diff --git a/src/coldfront_plugin_cloud/models/quota_models.py b/src/coldfront_plugin_cloud/models/quota_models.py new file mode 100644 index 00000000..a2d5143d --- /dev/null +++ b/src/coldfront_plugin_cloud/models/quota_models.py @@ -0,0 +1,66 @@ +from typing import Dict + +import pydantic +from pydantic import Field + + +class QuotaSpec(pydantic.BaseModel): + """ + Fields: + - quota_label: cluster-side identifier of the quota (must be unique across the `QuotaSpecs` dict) + - multiplier: multiplier applied to the allocation quantity (int, >= 0) + - static_quota: static extra quota added to every project (int, >= 0) + - resource_type: type of resource (e.g. "storage" for storage quotas) + - unit_suffix: textual unit suffix (e.g. "Gi", "Mi", "", etc.) + """ + + quota_label: str + multiplier: int = Field(0, ge=0) + static_quota: int = Field(0, ge=0) + unit_suffix: str = "" + resource_type: str = "" + invoice_name: str = "" + + class Config: + model_config = pydantic.ConfigDict(extra="ignore") + + def quota_by_su_quantity(self, quantity: int) -> int: + """ + Compute the quota for a given SU quantity using the formula: + quota = static_quota + multiplier * quantity + """ + return self.static_quota + self.multiplier * int(quantity) + + def formatted_quota(self, quota_value: int) -> str: + """ + Return the quota value with the unit_suffix appended as a string when a suffix is set. + """ + return f"{quota_value}{self.unit_suffix}" + + +class QuotaSpecs(pydantic.RootModel[Dict[str, QuotaSpec]]): + """ + Root model representing a mapping of display_name -> QuotaSpec. + + Validators: + - Ensure quota_label values are unique across all QuotaSpec entries. + """ + + @pydantic.model_validator(mode="after") + def validate_unique_labels(self): + # Ensure quota_label values are unique across the dict + labels = [q.quota_label for q in self.root.values()] + if len(labels) != len(set(labels)): + raise ValueError("Duplicate quota_label values found in QuotaSpecs") + + return self + + def get_quotas_by_type(self, resource_type: str) -> dict[str, QuotaSpec]: + """ + Return a dict of QuotaSpecs for a given resource_type. + """ + return { + name: spec + for name, spec in self.root.items() + if spec.resource_type == resource_type + } diff --git a/src/coldfront_plugin_cloud/usage_models.py b/src/coldfront_plugin_cloud/models/usage_models.py similarity index 100% rename from src/coldfront_plugin_cloud/usage_models.py rename to src/coldfront_plugin_cloud/models/usage_models.py diff --git a/src/coldfront_plugin_cloud/openshift.py b/src/coldfront_plugin_cloud/openshift.py index 44f8c7dd..f58dea29 100644 --- a/src/coldfront_plugin_cloud/openshift.py +++ b/src/coldfront_plugin_cloud/openshift.py @@ -157,22 +157,6 @@ class NotFound(ApiException): class OpenShiftResourceAllocator(base.ResourceAllocator): - QUOTA_KEY_MAPPING = { - attributes.QUOTA_LIMITS_CPU: lambda x: {"limits.cpu": f"{x * 1000}m"}, - attributes.QUOTA_LIMITS_MEMORY: lambda x: {"limits.memory": f"{x}Mi"}, - attributes.QUOTA_LIMITS_EPHEMERAL_STORAGE_GB: lambda x: { - "limits.ephemeral-storage": f"{x}Gi" - }, - attributes.QUOTA_REQUESTS_NESE_STORAGE: lambda x: { - "ocs-external-storagecluster-ceph-rbd.storageclass.storage.k8s.io/requests.storage": f"{x}Gi" - }, - attributes.QUOTA_REQUESTS_IBM_STORAGE: lambda x: { - "ibm-spectrum-scale-fileset.storageclass.storage.k8s.io/requests.storage": f"{x}Gi" - }, - attributes.QUOTA_REQUESTS_GPU: lambda x: {"requests.nvidia.com/gpu": f"{x}"}, - attributes.QUOTA_PVC: lambda x: {"persistentvolumeclaims": f"{x}"}, - } - resource_type = "openshift" project_name_max_length = 45 @@ -286,9 +270,9 @@ def set_quota(self, project_id): object in the project namespace with no extra scopes""" quota_spec = {} - for key, func in self.QUOTA_KEY_MAPPING.items(): + for key, quotaspec in self.resource_quotaspecs.root.items(): if (x := self.allocation.get_attribute(key)) is not None: - quota_spec.update(func(x)) + quota_spec.update({quotaspec.quota_label: quotaspec.formatted_quota(x)}) quota_def = { "metadata": {"name": f"{project_id}-project"}, diff --git a/src/coldfront_plugin_cloud/openshift_vm.py b/src/coldfront_plugin_cloud/openshift_vm.py index 17b47b8a..a475077c 100644 --- a/src/coldfront_plugin_cloud/openshift_vm.py +++ b/src/coldfront_plugin_cloud/openshift_vm.py @@ -1,29 +1,5 @@ -from coldfront_plugin_cloud import attributes, openshift +from coldfront_plugin_cloud import openshift class OpenShiftVMResourceAllocator(openshift.OpenShiftResourceAllocator): - QUOTA_KEY_MAPPING = { - attributes.QUOTA_LIMITS_CPU: lambda x: {"limits.cpu": f"{x * 1000}m"}, - attributes.QUOTA_LIMITS_MEMORY: lambda x: {"limits.memory": f"{x}Mi"}, - attributes.QUOTA_LIMITS_EPHEMERAL_STORAGE_GB: lambda x: { - "limits.ephemeral-storage": f"{x}Gi" - }, - attributes.QUOTA_REQUESTS_NESE_STORAGE: lambda x: { - "ocs-external-storagecluster-ceph-rbd.storageclass.storage.k8s.io/requests.storage": f"{x}Gi" - }, - attributes.QUOTA_REQUESTS_IBM_STORAGE: lambda x: { - "ibm-spectrum-scale-fileset.storageclass.storage.k8s.io/requests.storage": f"{x}Gi" - }, - attributes.QUOTA_REQUESTS_VM_GPU_A100_SXM4: lambda x: { - "requests.nvidia.com/A100_SXM4_40GB": f"{x}" - }, - attributes.QUOTA_REQUESTS_VM_GPU_V100: lambda x: { - "requests.nvidia.com/GV100GL_Tesla_V100": f"{x}" - }, - attributes.QUOTA_REQUESTS_VM_GPU_H100: lambda x: { - "requests.nvidia.com/H100_SXM5_80GB": f"{x}" - }, - attributes.QUOTA_PVC: lambda x: {"persistentvolumeclaims": f"{x}"}, - } - resource_type = "openshift_vm" diff --git a/src/coldfront_plugin_cloud/openstack.py b/src/coldfront_plugin_cloud/openstack.py index 65f03205..5752e303 100644 --- a/src/coldfront_plugin_cloud/openstack.py +++ b/src/coldfront_plugin_cloud/openstack.py @@ -64,40 +64,6 @@ def get_session_for_resource(resource): class OpenStackResourceAllocator(base.ResourceAllocator): - # Map the attribute name in ColdFront, to the client of the respective - # service, the version of the API, and the key in the payload. - QUOTA_KEY_MAPPING = { - "compute": { - "keys": { - attributes.QUOTA_INSTANCES: "instances", - attributes.QUOTA_VCPU: "cores", - attributes.QUOTA_RAM: "ram", - }, - }, - "network": { - "keys": { - attributes.QUOTA_FLOATING_IPS: "floatingip", - } - }, - "object": { - "keys": { - attributes.QUOTA_OBJECT_GB: "x-account-meta-quota-bytes", - } - }, - "volume": { - "keys": { - attributes.QUOTA_VOLUMES: "volumes", - attributes.QUOTA_VOLUMES_GB: "gigabytes", - } - }, - } - - QUOTA_KEY_MAPPING_ALL_KEYS = { - quota_key: quota_name - for k in QUOTA_KEY_MAPPING.values() - for quota_key, quota_name in k["keys"].items() - } - resource_type = "openstack" project_name_max_length = 64 @@ -141,6 +107,22 @@ def object(self, project_id=None, session=None) -> swiftclient.Connection: preauthurl=preauth_url, ) + def _extract_quota_label(self, quotaspec) -> list[str]: + """Returns [service_name, quota_label] for a given quotaspec""" + return quotaspec.quota_label.split(".", 1) + + @functools.lru_cache() + def _get_resource_quota_labels_by_service( + self, requested_service_name + ) -> list[str]: + """Returns a list of quota labels for a given service name (i.e "compute")""" + quota_labels = [] + for quotaspec in self.resource_quotaspecs.root.values(): + service_name, quota_label = self._extract_quota_label(quotaspec) + if service_name == requested_service_name: + quota_labels.append(quota_label) + return quota_labels + def set_project_configuration(self, project_id, dry_run=False): pass @@ -167,15 +149,17 @@ def set_quota(self, project_id): # If an attribute with the appropriate name is associated with an # allocation, set that as the quota. Otherwise, multiply # the quantity attribute via the mapping table above. - for service_name, service in self.QUOTA_KEY_MAPPING.items(): - # No need to do any calculations here, just go through each service - # and set the value in the attribute. - payload = dict() - for coldfront_attr, openstack_key in service["keys"].items(): - value = self.allocation.get_attribute(coldfront_attr) - if value is not None: - payload[openstack_key] = value + # No need to do any calculations here, just go through each service + # and set the value in the attribute. + payloads: dict[str, dict[str, str]] = dict() + for display_name, quotaspec in self.resource_quotaspecs.root.items(): + service_name, quota_label = self._extract_quota_label(quotaspec) + value = self.allocation.get_attribute(display_name) + if value is not None: + payloads.setdefault(service_name, dict())[quota_label] = value + + for service_name, payload in payloads.items(): if not payload: # Skip if service doesn't have any associated attributes continue @@ -189,14 +173,19 @@ def set_quota(self, project_id): elif service_name == "object": self._set_object_quota(project_id, payload) + # self.network.update_quota(project_id, body={"quota": payloads.get("network", {})}) + # self.volume.quotas.update(project_id, **payloads.get("volume", {})) + # self.compute.quotas.update(project_id, **payloads.get("compute", {})) + # self._set_object_quota(project_id, payloads["object"]) + def _set_object_quota(self, project_id, payload): try: # Note(knikolla): For consistency with other OpenStack # quotas we're storing this as GB on the attribute and # converting to bytes for Swift. - obj_q_mapping = self.QUOTA_KEY_MAPPING["object"]["keys"][ - attributes.QUOTA_OBJECT_GB - ] + _, obj_q_mapping = self._extract_quota_label( + self.resource_quotaspecs.root[attributes.QUOTA_OBJECT_GB] + ) payload[obj_q_mapping] *= GB_IN_BYTES if payload[obj_q_mapping] <= 0: payload[obj_q_mapping] = 1 @@ -245,7 +234,7 @@ def _init_rgw_for_project(self, project_id): def _get_network_quota(self, quotas, project_id): network_quota = self.network.show_quota(project_id)["quota"] - for k in self.QUOTA_KEY_MAPPING["network"]["keys"].values(): + for k in self._get_resource_quota_labels_by_service("network"): quotas[k] = network_quota.get(k) return quotas @@ -254,16 +243,18 @@ def get_quota(self, project_id): quotas = dict() compute_quota = self.compute.quotas.get(project_id) - for k in self.QUOTA_KEY_MAPPING["compute"]["keys"].values(): + for k in self._get_resource_quota_labels_by_service("compute"): quotas[k] = compute_quota.__getattr__(k) volume_quota = self.volume.quotas.get(project_id) - for k in self.QUOTA_KEY_MAPPING["volume"]["keys"].values(): + for k in self._get_resource_quota_labels_by_service("volume"): quotas[k] = volume_quota.__getattr__(k) quotas = self._get_network_quota(quotas, project_id) - key = self.QUOTA_KEY_MAPPING["object"]["keys"][attributes.QUOTA_OBJECT_GB] + _, key = self._extract_quota_label( + self.resource_quotaspecs.root[attributes.QUOTA_OBJECT_GB] + ) try: swift = self.object(project_id).head_account() quotas[key] = int(int(swift.get(key)) / GB_IN_BYTES) diff --git a/src/coldfront_plugin_cloud/tasks.py b/src/coldfront_plugin_cloud/tasks.py index 262f6845..130e56b2 100644 --- a/src/coldfront_plugin_cloud/tasks.py +++ b/src/coldfront_plugin_cloud/tasks.py @@ -17,82 +17,6 @@ logger = logging.getLogger(__name__) -# Map the amount of quota that 1 unit of `quantity` gets you -# This is multiplied to the quantity of that resource allocation. -UNIT_QUOTA_MULTIPLIERS = { - "openstack": { - attributes.QUOTA_INSTANCES: 1, - attributes.QUOTA_VCPU: 1, - attributes.QUOTA_RAM: 4096, - attributes.QUOTA_VOLUMES: 2, - attributes.QUOTA_VOLUMES_GB: 20, - attributes.QUOTA_FLOATING_IPS: 0, - attributes.QUOTA_OBJECT_GB: 1, - attributes.QUOTA_GPU: 0, - }, - "openshift": { - attributes.QUOTA_LIMITS_CPU: 1, - attributes.QUOTA_LIMITS_MEMORY: 4096, - attributes.QUOTA_LIMITS_EPHEMERAL_STORAGE_GB: 5, - attributes.QUOTA_REQUESTS_NESE_STORAGE: 20, - attributes.QUOTA_REQUESTS_IBM_STORAGE: 0, - attributes.QUOTA_REQUESTS_GPU: 0, - attributes.QUOTA_PVC: 2, - }, - "openshift_vm": { - attributes.QUOTA_LIMITS_CPU: 1, - attributes.QUOTA_LIMITS_MEMORY: 4096, - attributes.QUOTA_LIMITS_EPHEMERAL_STORAGE_GB: 5, - attributes.QUOTA_REQUESTS_NESE_STORAGE: 20, - attributes.QUOTA_REQUESTS_IBM_STORAGE: 0, - attributes.QUOTA_REQUESTS_VM_GPU_A100_SXM4: 0, - attributes.QUOTA_REQUESTS_VM_GPU_V100: 0, - attributes.QUOTA_REQUESTS_VM_GPU_H100: 0, - attributes.QUOTA_PVC: 2, - }, - "esi": {attributes.QUOTA_FLOATING_IPS: 0, attributes.QUOTA_NETWORKS: 0}, -} - -# The amount of quota that every projects gets, -# regardless of units of quantity. This is added -# on top of the multiplication. -STATIC_QUOTA = { - "openstack": { - attributes.QUOTA_FLOATING_IPS: 2, - attributes.QUOTA_GPU: 0, - }, - "openshift": { - attributes.QUOTA_REQUESTS_GPU: 0, - }, - "esi": {attributes.QUOTA_FLOATING_IPS: 1, attributes.QUOTA_NETWORKS: 1}, - "openshift_vm": { - attributes.QUOTA_REQUESTS_VM_GPU_A100_SXM4: 0, - attributes.QUOTA_REQUESTS_VM_GPU_V100: 0, - attributes.QUOTA_REQUESTS_VM_GPU_H100: 0, - }, -} - - -def get_expected_attributes(allocator: base.ResourceAllocator): - """Based on the allocator's resource type, return the expected quotas attributes the allocation should have""" - resource_name = allocator.resource_type - resource_expected_quotas = UNIT_QUOTA_MULTIPLIERS[resource_name].copy() - - # If the resource attribute is not set (i.e for OpenStack resources), get_attribute returns None - is_ibm_storage_available = allocator.resource.get_attribute( - attributes.RESOURCE_IBM_AVAILABLE - ) - is_ibm_storage_available = ( - is_ibm_storage_available and is_ibm_storage_available.lower() == "true" - ) - if "openshift" in resource_name and not is_ibm_storage_available: - resource_expected_quotas.pop( - attributes.QUOTA_REQUESTS_IBM_STORAGE, None - ) # The resource may or may not already have this attribute - - return list(resource_expected_quotas.keys()) - - def find_allocator(allocation) -> base.ResourceAllocator: allocators = { "openstack": openstack.OpenStackResourceAllocator, @@ -115,13 +39,10 @@ def set_quota_attributes(): allocation.quantity = 1 # Calculate the quota for the project, and set the attribute for each element - expected_coldfront_attrs = get_expected_attributes(allocator) - for coldfront_attr in expected_coldfront_attrs: + resource_quotaspecs = allocator.resource_quotaspecs + for coldfront_attr, quota_spec in resource_quotaspecs.root.items(): if not allocation.get_attribute(coldfront_attr): - value = allocation.quantity * UNIT_QUOTA_MULTIPLIERS[ - allocator.resource_type - ].get(coldfront_attr, 0) - value += STATIC_QUOTA[allocator.resource_type].get(coldfront_attr, 0) + value = quota_spec.quota_by_su_quantity(allocation.quantity) utils.set_attribute_on_allocation(allocation, coldfront_attr, value) allocation = Allocation.objects.get(pk=allocation_pk) diff --git a/src/coldfront_plugin_cloud/tests/base.py b/src/coldfront_plugin_cloud/tests/base.py index 3040b1e6..7e1b7d68 100644 --- a/src/coldfront_plugin_cloud/tests/base.py +++ b/src/coldfront_plugin_cloud/tests/base.py @@ -87,20 +87,20 @@ def new_openstack_resource( @staticmethod def new_openshift_resource( name=None, + internal_name=None, api_url=None, idp=None, for_virtualization=False, - ibm_storage_available=False, ) -> Resource: resource_name = name or uuid.uuid4().hex call_command( "add_openshift_resource", name=resource_name, + internal_name=internal_name, api_url=api_url or "https://onboarding-onboarding.cluster.local:6443", idp=idp or "developer", for_virtualization=for_virtualization, - ibm_storage_available=ibm_storage_available, ) return Resource.objects.get(name=resource_name) diff --git a/src/coldfront_plugin_cloud/tests/functional/esi/test_allocations.py b/src/coldfront_plugin_cloud/tests/functional/esi/test_allocations.py index 141af0fa..77898b8e 100644 --- a/src/coldfront_plugin_cloud/tests/functional/esi/test_allocations.py +++ b/src/coldfront_plugin_cloud/tests/functional/esi/test_allocations.py @@ -17,9 +17,28 @@ class TestAllocation(base.TestBase): def setUp(self) -> None: super().setUp() + resource_name = "ESI" self.resource = self.new_esi_resource( name="ESI", auth_url=os.getenv("OS_AUTH_URL") ) + + call_command( + "add_quota_to_resource", + display_name=attributes.QUOTA_NETWORKS, + resource_name=resource_name, + quota_label="network.network", + multiplier=0, + static_quota=1, + ) + call_command( + "add_quota_to_resource", + display_name=attributes.QUOTA_FLOATING_IPS, + resource_name=resource_name, + quota_label="network.floatingip", + multiplier=0, + static_quota=1, + ) + self.session = openstack.get_session_for_resource(self.resource) self.identity = client.Client(session=self.session) self.compute = novaclient.Client(session=self.session, version=2) diff --git a/src/coldfront_plugin_cloud/tests/functional/openshift/test_allocation.py b/src/coldfront_plugin_cloud/tests/functional/openshift/test_allocation.py index 76229e01..99599e44 100644 --- a/src/coldfront_plugin_cloud/tests/functional/openshift/test_allocation.py +++ b/src/coldfront_plugin_cloud/tests/functional/openshift/test_allocation.py @@ -1,7 +1,6 @@ import os import time import unittest -from unittest import mock import uuid from coldfront_plugin_cloud import attributes, openshift, tasks, utils @@ -18,8 +17,8 @@ def setUp(self) -> None: self.resource = self.new_openshift_resource( name="Microshift", api_url=os.getenv("OS_API_URL"), - ibm_storage_available=True, ) + call_command("register_default_quotas", apply=True) def test_new_allocation(self): user = self.new_user() @@ -151,7 +150,6 @@ def test_new_allocation_quota(self): "limits.memory": "8Gi", "limits.ephemeral-storage": "10Gi", "ocs-external-storagecluster-ceph-rbd.storageclass.storage.k8s.io/requests.storage": "40Gi", - "ibm-spectrum-scale-fileset.storageclass.storage.k8s.io/requests.storage": "0", "requests.nvidia.com/gpu": "0", "persistentvolumeclaims": "4", }, @@ -195,7 +193,6 @@ def test_new_allocation_quota(self): "limits.memory": "8Gi", "limits.ephemeral-storage": "50Gi", "ocs-external-storagecluster-ceph-rbd.storageclass.storage.k8s.io/requests.storage": "100Gi", - "ibm-spectrum-scale-fileset.storageclass.storage.k8s.io/requests.storage": "0", "requests.nvidia.com/gpu": "1", "persistentvolumeclaims": "10", }, @@ -225,7 +222,6 @@ def test_reactivate_allocation(self): "limits.memory": "8Gi", "limits.ephemeral-storage": "10Gi", "ocs-external-storagecluster-ceph-rbd.storageclass.storage.k8s.io/requests.storage": "40Gi", - "ibm-spectrum-scale-fileset.storageclass.storage.k8s.io/requests.storage": "0", "requests.nvidia.com/gpu": "0", "persistentvolumeclaims": "4", }, @@ -247,7 +243,6 @@ def test_reactivate_allocation(self): "limits.memory": "8Gi", "limits.ephemeral-storage": "10Gi", "ocs-external-storagecluster-ceph-rbd.storageclass.storage.k8s.io/requests.storage": "40Gi", - "ibm-spectrum-scale-fileset.storageclass.storage.k8s.io/requests.storage": "0", "requests.nvidia.com/gpu": "0", "persistentvolumeclaims": "4", }, @@ -330,59 +325,8 @@ def test_create_incomplete(self): ) ) - @mock.patch.object( - tasks, - "UNIT_QUOTA_MULTIPLIERS", - { - "openshift": { - attributes.QUOTA_LIMITS_CPU: 1, - } - }, - ) - def test_allocation_new_attribute(self): - """When a new attribute is introduced, but pre-existing allocations don't have it""" - user = self.new_user() - project = self.new_project(pi=user) - allocation = self.new_allocation(project, self.resource, 2) - allocator = openshift.OpenShiftResourceAllocator(self.resource, allocation) - - tasks.activate_allocation(allocation.pk) - allocation.refresh_from_db() - - project_id = allocation.get_attribute(attributes.ALLOCATION_PROJECT_ID) - - self.assertEqual(allocation.get_attribute(attributes.QUOTA_LIMITS_CPU), 2 * 1) - - quota = allocator.get_quota(project_id) - self.assertEqual( - quota, - { - "limits.cpu": "2", - }, - ) - - # Add a new attribute for Openshift - tasks.UNIT_QUOTA_MULTIPLIERS["openshift"][attributes.QUOTA_LIMITS_MEMORY] = 4096 - - call_command("validate_allocations", apply=True) - allocation.refresh_from_db() - - self.assertEqual(allocation.get_attribute(attributes.QUOTA_LIMITS_CPU), 2 * 1) - self.assertEqual( - allocation.get_attribute(attributes.QUOTA_LIMITS_MEMORY), 2 * 4096 - ) - - quota = allocator.get_quota(project_id) - self.assertEqual( - quota, - { - "limits.cpu": "2", - "limits.memory": "8Gi", - }, - ) - def test_migrate_quota_field_names(self): - """When a quota key in QUOTA_KEY_MAPPING changes to a new value, validate_allocations should update the quota.""" + """When a quota changes to a new label name, validate_allocations should update the quota.""" user = self.new_user() project = self.new_project(pi=user) allocation = self.new_allocation(project, self.resource, 1) @@ -401,75 +345,24 @@ def test_migrate_quota_field_names(self): "limits.memory": "4Gi", "limits.ephemeral-storage": "5Gi", "ocs-external-storagecluster-ceph-rbd.storageclass.storage.k8s.io/requests.storage": "20Gi", - "ibm-spectrum-scale-fileset.storageclass.storage.k8s.io/requests.storage": "0", "requests.nvidia.com/gpu": "0", "persistentvolumeclaims": "2", }, ) # Now migrate NESE Storage quota field (ocs-external...) to fake storage quota - with unittest.mock.patch.dict( - openshift.OpenShiftResourceAllocator.QUOTA_KEY_MAPPING, - { - attributes.QUOTA_REQUESTS_NESE_STORAGE: lambda x: { - "fake-storage.storageclass.storage.k8s.io/requests.storage": f"{x}Gi" - } - }, - ): - call_command("validate_allocations", apply=True) - - # Check the quota after migration - quota = allocator.get_quota(project_id) - self.assertEqual( - quota, - { - "limits.cpu": "1", - "limits.memory": "4Gi", - "limits.ephemeral-storage": "5Gi", - "fake-storage.storageclass.storage.k8s.io/requests.storage": "20Gi", # Migrated key - "ibm-spectrum-scale-fileset.storageclass.storage.k8s.io/requests.storage": "0", - "requests.nvidia.com/gpu": "0", - "persistentvolumeclaims": "2", - }, - ) - - def test_ibm_storage_not_available(self): - """If IBM Scale storage is not available, the corresponding quotas should not be set.""" - user = self.new_user() - project = self.new_project(pi=user) - - # Set ibm storage as not available - self.resource.resourceattribute_set.filter( - resource_attribute_type__name=attributes.RESOURCE_IBM_AVAILABLE - ).update(value="false") - allocation = self.new_allocation(project, self.resource, 1) - allocator = openshift.OpenShiftResourceAllocator(self.resource, allocation) - - tasks.activate_allocation(allocation.pk) - allocation.refresh_from_db() - - project_id = allocation.get_attribute(attributes.ALLOCATION_PROJECT_ID) - - quota = allocator.get_quota(project_id) - self.assertEqual( - quota, - { - "limits.cpu": "1", - "limits.memory": "4Gi", - "limits.ephemeral-storage": "5Gi", - "ocs-external-storagecluster-ceph-rbd.storageclass.storage.k8s.io/requests.storage": "20Gi", - "requests.nvidia.com/gpu": "0", - "persistentvolumeclaims": "2", - }, + call_command( + "add_quota_to_resource", + display_name=attributes.QUOTA_REQUESTS_NESE_STORAGE, + resource_name=self.resource.name, + quota_label="fake-storage.storageclass.storage.k8s.io/requests.storage", + multiplier=20, + static_quota=0, + unit_suffix="Gi", ) - - # Now set IBM Scale storage as available - self.resource.resourceattribute_set.filter( - resource_attribute_type__name=attributes.RESOURCE_IBM_AVAILABLE - ).update(value="true") - call_command("validate_allocations", apply=True) + # Check the quota after migration quota = allocator.get_quota(project_id) self.assertEqual( quota, @@ -477,8 +370,7 @@ def test_ibm_storage_not_available(self): "limits.cpu": "1", "limits.memory": "4Gi", "limits.ephemeral-storage": "5Gi", - "ocs-external-storagecluster-ceph-rbd.storageclass.storage.k8s.io/requests.storage": "20Gi", - "ibm-spectrum-scale-fileset.storageclass.storage.k8s.io/requests.storage": "0", # Newly added IBM key + "fake-storage.storageclass.storage.k8s.io/requests.storage": "20Gi", # Migrated key "requests.nvidia.com/gpu": "0", "persistentvolumeclaims": "2", }, @@ -596,9 +488,130 @@ def test_preexisting_project(self): "limits.memory": "4Gi", "limits.ephemeral-storage": "5Gi", "ocs-external-storagecluster-ceph-rbd.storageclass.storage.k8s.io/requests.storage": "20Gi", - "ibm-spectrum-scale-fileset.storageclass.storage.k8s.io/requests.storage": "0", "requests.nvidia.com/gpu": "0", "persistentvolumeclaims": "2", }, ) assert set([user.username]) == allocator.get_users(project_id) + + def test_remove_quota(self): + """Test removing a quota from a resource and validating allocations. + After removal, prior allocations should still have the quota, but new allocations should not.""" + user = self.new_user() + project = self.new_project(pi=user) + allocation_1 = self.new_allocation(project, self.resource, 1) + allocator_1 = openshift.OpenShiftResourceAllocator(self.resource, allocation_1) + + tasks.activate_allocation(allocation_1.pk) + allocation_1.refresh_from_db() + project_id_1 = allocation_1.get_attribute(attributes.ALLOCATION_PROJECT_ID) + + quota = allocator_1.get_quota(project_id_1) + self.assertIn( + "ocs-external-storagecluster-ceph-rbd.storageclass.storage.k8s.io/requests.storage", + quota, + ) + + # Now remove NESE Storage quota from resource + call_command( + "remove_quota_from_resource", + resource_name=self.resource.name, + display_name=attributes.QUOTA_REQUESTS_NESE_STORAGE, + apply=True, + ) + call_command( + "validate_allocations", apply=True + ) # This should have not removed the quota from prior allocation (Have no impact) + + quota = allocator_1.get_quota(project_id_1) + self.assertIn( + "ocs-external-storagecluster-ceph-rbd.storageclass.storage.k8s.io/requests.storage", + quota, + ) + self.assertIsNotNone( + allocation_1.get_attribute(attributes.QUOTA_REQUESTS_NESE_STORAGE) + ) + + # Create second allocation, which should not have the NESE storage quota + self.resource.refresh_from_db() + project_2 = self.new_project(pi=user) + allocation_2 = self.new_allocation(project_2, self.resource, 1) + allocator_2 = openshift.OpenShiftResourceAllocator(self.resource, allocation_2) + tasks.activate_allocation(allocation_2.pk) + allocation_2.refresh_from_db() + project_id_2 = allocation_2.get_attribute(attributes.ALLOCATION_PROJECT_ID) + + quota_2 = allocator_2.get_quota(project_id_2) + self.assertNotIn( + "ocs-external-storagecluster-ceph-rbd.storageclass.storage.k8s.io/requests.storage", + quota_2, + ) + self.assertIsNone( + allocation_2.get_attribute(attributes.QUOTA_REQUESTS_NESE_STORAGE) + ) + + +class TestAllocationNewQuota(base.TestBase): + def setUp(self) -> None: + super().setUp() + self.resource = self.new_openshift_resource( + name="Microshift", + api_url=os.getenv("OS_API_URL"), + ) + call_command( + "add_quota_to_resource", + display_name=attributes.QUOTA_LIMITS_CPU, + resource_name=self.resource.name, + quota_label="limits.cpu", + multiplier=1, + ) + + def test_allocation_new_attribute(self): + """When a new attribute is introduced, but pre-existing allocations don't have it""" + user = self.new_user() + project = self.new_project(pi=user) + allocation = self.new_allocation(project, self.resource, 2) + allocator = openshift.OpenShiftResourceAllocator(self.resource, allocation) + + tasks.activate_allocation(allocation.pk) + allocation.refresh_from_db() + + project_id = allocation.get_attribute(attributes.ALLOCATION_PROJECT_ID) + + self.assertEqual(allocation.get_attribute(attributes.QUOTA_LIMITS_CPU), 2 * 1) + + quota = allocator.get_quota(project_id) + self.assertEqual( + quota, + { + "limits.cpu": "2", + }, # Note no ceph storage quota + ) + + # Add a new attribute for Openshift + call_command( + "add_quota_to_resource", + display_name=attributes.QUOTA_REQUESTS_NESE_STORAGE, + resource_name=self.resource.name, + quota_label="ocs-external-storagecluster-ceph-rbd.storageclass.storage.k8s.io/requests.storage", + multiplier=20, + static_quota=0, + unit_suffix="Gi", + ) + + call_command("validate_allocations", apply=True) + allocation.refresh_from_db() + + self.assertEqual(allocation.get_attribute(attributes.QUOTA_LIMITS_CPU), 2 * 1) + self.assertEqual( + allocation.get_attribute(attributes.QUOTA_REQUESTS_NESE_STORAGE), 2 * 20 + ) + + quota = allocator.get_quota(project_id) + self.assertEqual( + quota, + { + "limits.cpu": "2", + "ocs-external-storagecluster-ceph-rbd.storageclass.storage.k8s.io/requests.storage": "40Gi", + }, + ) diff --git a/src/coldfront_plugin_cloud/tests/functional/openshift_vm/test_allocation.py b/src/coldfront_plugin_cloud/tests/functional/openshift_vm/test_allocation.py index 4e8b4c4b..806c0bc0 100644 --- a/src/coldfront_plugin_cloud/tests/functional/openshift_vm/test_allocation.py +++ b/src/coldfront_plugin_cloud/tests/functional/openshift_vm/test_allocation.py @@ -4,6 +4,8 @@ from coldfront_plugin_cloud import attributes, openshift_vm, tasks from coldfront_plugin_cloud.tests import base +from django.core.management import call_command + @unittest.skipUnless(os.getenv("FUNCTIONAL_TESTS"), "Functional tests not enabled.") class TestAllocation(base.TestBase): @@ -14,6 +16,40 @@ def setUp(self) -> None: api_url=os.getenv("OS_API_URL"), for_virtualization=True, ) + call_command("register_default_quotas", apply=True) + call_command( + "remove_quota_from_resource", + resource_name=self.resource.name, + display_name=attributes.QUOTA_REQUESTS_GPU, + apply=True, + ) + call_command( + "add_quota_to_resource", + display_name=attributes.QUOTA_REQUESTS_VM_GPU_A100_SXM4, + resource_name=self.resource.name, + quota_label="requests.nvidia.com/A100_SXM4_40GB", + multiplier=0, + static_quota=0, + unit_suffix="", + ) + call_command( + "add_quota_to_resource", + display_name=attributes.QUOTA_REQUESTS_VM_GPU_V100, + resource_name=self.resource.name, + quota_label="requests.nvidia.com/GV100GL_Tesla_V100", + multiplier=0, + static_quota=0, + unit_suffix="", + ) + call_command( + "add_quota_to_resource", + display_name=attributes.QUOTA_REQUESTS_VM_GPU_H100, + resource_name=self.resource.name, + quota_label="requests.nvidia.com/H100_SXM5_80GB", + multiplier=0, + static_quota=0, + unit_suffix="", + ) def test_new_allocation(self): # TODO must wait until we know what the quota values for openshift_vm are diff --git a/src/coldfront_plugin_cloud/tests/functional/openstack/test_allocation.py b/src/coldfront_plugin_cloud/tests/functional/openstack/test_allocation.py index 20a24a61..6542f237 100644 --- a/src/coldfront_plugin_cloud/tests/functional/openstack/test_allocation.py +++ b/src/coldfront_plugin_cloud/tests/functional/openstack/test_allocation.py @@ -20,6 +20,7 @@ def setUp(self) -> None: self.resource = self.new_openstack_resource( name="Devstack", auth_url=os.getenv("OS_AUTH_URL") ) + call_command("register_default_quotas", apply=True) self.session = openstack.get_session_for_resource(self.resource) self.identity = client.Client(session=self.session) self.compute = novaclient.Client(session=self.session, version=2) @@ -114,10 +115,8 @@ def test_new_allocation(self): expected_quota.pop("x-account-meta-quota-bytes") self.assertEqual(expected_quota, resulting_quota) - # Check correct attributes - for attr in attributes.ALLOCATION_QUOTA_ATTRIBUTES: - if "OpenStack" in attr.name: - self.assertIsNotNone(allocation.get_attribute(attr.name)) + for attr in allocator.resource_quotaspecs.root.keys(): + self.assertIsNotNone(allocation.get_attribute(attr)) def test_new_allocation_with_quantity(self): user = self.new_user() @@ -213,9 +212,9 @@ def test_new_allocation_with_quantity(self): # Change allocation attributes for object store quota current_quota = allocator.get_quota(openstack_project.id) - obj_key = openstack.OpenStackResourceAllocator.QUOTA_KEY_MAPPING["object"][ - "keys" - ][attributes.QUOTA_OBJECT_GB] + obj_key = allocator.resource_quotaspecs.root[ + attributes.QUOTA_OBJECT_GB + ].quota_label if obj_key in current_quota.keys(): utils.set_attribute_on_allocation(allocation, attributes.QUOTA_OBJECT_GB, 6) self.assertEqual(allocation.get_attribute(attributes.QUOTA_OBJECT_GB), 6) diff --git a/src/coldfront_plugin_cloud/tests/unit/openshift/base.py b/src/coldfront_plugin_cloud/tests/unit/openshift/base.py index 82662ab8..f8ad60a5 100644 --- a/src/coldfront_plugin_cloud/tests/unit/openshift/base.py +++ b/src/coldfront_plugin_cloud/tests/unit/openshift/base.py @@ -4,10 +4,20 @@ from coldfront_plugin_cloud.openshift import OpenShiftResourceAllocator +class TestOpenshiftResourceAllocator(OpenShiftResourceAllocator): + def __init__(self): + self.resource = mock.Mock() + self.allocation = mock.Mock() + self.resource_quotaspecs = mock.Mock() + self.id_provider = "fake_idp" + self.k8_client = mock.Mock() + + self.verify = False + self.safe_resource_name = "foo" + self.apis = {} + self.member_role_name = "admin" + + class TestUnitOpenshiftBase(base.TestBase): def setUp(self) -> None: - mock_resource = mock.Mock() - mock_allocation = mock.Mock() - self.allocator = OpenShiftResourceAllocator(mock_resource, mock_allocation) - self.allocator.id_provider = "fake_idp" - self.allocator.k8_client = mock.Mock() + self.allocator = TestOpenshiftResourceAllocator() diff --git a/src/coldfront_plugin_cloud/tests/unit/openshift/test_rbac.py b/src/coldfront_plugin_cloud/tests/unit/openshift/test_rbac.py index b7898f7c..54ed32ca 100644 --- a/src/coldfront_plugin_cloud/tests/unit/openshift/test_rbac.py +++ b/src/coldfront_plugin_cloud/tests/unit/openshift/test_rbac.py @@ -2,19 +2,10 @@ import kubernetes.dynamic.exceptions as kexc -from coldfront_plugin_cloud.tests import base -from coldfront_plugin_cloud.openshift import OpenShiftResourceAllocator +from coldfront_plugin_cloud.tests.unit.openshift import base -class TestMocOpenShiftRBAC(base.TestBase): - def setUp(self) -> None: - mock_resource = mock.Mock() - mock_allocation = mock.Mock() - self.allocator = OpenShiftResourceAllocator(mock_resource, mock_allocation) - self.allocator.id_provider = "fake_idp" - self.allocator.k8_client = mock.Mock() - self.allocator.member_role_name = "admin" - +class TestMocOpenShiftRBAC(base.TestUnitOpenshiftBase): def test_user_in_rolebindings_false(self): fake_rb = { "subjects": [ diff --git a/src/coldfront_plugin_cloud/tests/unit/test_calculate_quota_unit_hours.py b/src/coldfront_plugin_cloud/tests/unit/test_calculate_quota_unit_hours.py index f71e12a5..dedacac6 100644 --- a/src/coldfront_plugin_cloud/tests/unit/test_calculate_quota_unit_hours.py +++ b/src/coldfront_plugin_cloud/tests/unit/test_calculate_quota_unit_hours.py @@ -19,15 +19,25 @@ class TestCalculateAllocationQuotaHours(base.TestBase): + def setUp(self): + super().setUp() + self.resource = self.new_openshift_resource( + name="", + ) + call_command( + "add_quota_to_resource", + display_name=attributes.QUOTA_LIMITS_EPHEMERAL_STORAGE_GB, + resource_name=self.resource.name, + quota_label="limits.ephemeral-storage", + multiplier=5, + unit_suffix="Gi", + ) + @patch("coldfront_plugin_cloud.utils.load_outages_from_nerc_rates") def test_new_allocation_quota(self, mock_load_outages): """Test quota calculation with nerc-rates outages mocked.""" mock_load_outages.return_value = [] - self.resource = self.new_openshift_resource( - name="", - ) - with freezegun.freeze_time("2020-03-15 00:01:00"): user = self.new_user() project = self.new_project(pi=user) @@ -94,9 +104,6 @@ def test_new_allocation_quota(self, mock_load_outages): def test_new_allocation_quota_expired(self): """Test that expiration doesn't affect invoicing.""" - self.resource = self.new_openshift_resource( - name="", - ) user = self.new_user() project = self.new_project(pi=user) allocation = self.new_allocation(project, self.resource, 2) @@ -127,9 +134,6 @@ def test_new_allocation_quota_expired(self): def test_new_allocation_quota_denied(self): """Test a simple case of invoicing until a status change.""" - self.resource = self.new_openshift_resource( - name="", - ) user = self.new_user() project = self.new_project(pi=user) allocation = self.new_allocation(project, self.resource, 2) @@ -157,9 +161,6 @@ def test_new_allocation_quota_denied(self): def test_new_allocation_quota_last_revoked(self): """Test that we correctly distinguish the last transition to an unbilled state.""" - self.resource = self.new_openshift_resource( - name="", - ) user = self.new_user() project = self.new_project(pi=user) allocation = self.new_allocation(project, self.resource, 2) @@ -202,9 +203,6 @@ def test_new_allocation_quota_last_revoked(self): self.assertEqual(value, 144) def test_new_allocation_quota_new(self): - self.resource = self.new_openshift_resource( - name="", - ) user = self.new_user() project = self.new_project(pi=user) allocation = self.new_allocation(project, self.resource, 2) @@ -220,9 +218,6 @@ def test_new_allocation_quota_new(self): self.assertEqual(value, 0) def test_new_allocation_quota_never_approved(self): - self.resource = self.new_openshift_resource( - name="", - ) user = self.new_user() project = self.new_project(pi=user) allocation = self.new_allocation(project, self.resource, 2) @@ -242,9 +237,6 @@ def test_new_allocation_quota_never_approved(self): def test_change_request_decrease(self): """Test for when a change request decreases the quota""" - self.resource = self.new_openshift_resource( - name="", - ) user = self.new_user() project = self.new_project(pi=user) allocation = self.new_allocation(project, self.resource, 2) @@ -288,9 +280,6 @@ def test_change_request_decrease(self): def test_change_request_increase(self): """Test for when a change request increases the quota""" - self.resource = self.new_openshift_resource( - name="", - ) user = self.new_user() project = self.new_project(pi=user) allocation = self.new_allocation(project, self.resource, 2) @@ -334,9 +323,6 @@ def test_change_request_increase(self): def test_change_request_decrease_multiple(self): """Test for when multiple different change request decreases the quota""" - self.resource = self.new_openshift_resource( - name="", - ) user = self.new_user() project = self.new_project(pi=user) allocation = self.new_allocation(project, self.resource, 2) @@ -397,9 +383,6 @@ def test_change_request_decrease_multiple(self): self.assertEqual(value, 48) def test_new_allocation_quota_change_request(self): - self.resource = self.new_openshift_resource( - name="", - ) user = self.new_user() project = self.new_project(pi=user) allocation = self.new_allocation(project, self.resource, 2) @@ -628,6 +611,15 @@ def test_nerc_outages_integration(self, mock_rates_loader): resource = self.new_openstack_resource( name="TEST-RESOURCE", internal_name="test-service" ) + call_command( + "add_quota_to_resource", + display_name=attributes.QUOTA_VOLUMES_GB, + resource_name=resource.name, + quota_label="volume.gigabytes", + multiplier=20, + resource_type="storage", + invoice_name="OpenStack Storage", + ) allocation = self.new_allocation(project, resource, 100) for attr, val in [ (attributes.ALLOCATION_PROJECT_NAME, "test"), diff --git a/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py b/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py index 9b43217d..a52c2174 100644 --- a/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py +++ b/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py @@ -8,7 +8,7 @@ Command, ) from coldfront_plugin_cloud import attributes -from coldfront_plugin_cloud import usage_models +from coldfront_plugin_cloud.models import usage_models from coldfront_plugin_cloud.tests import base from coldfront_plugin_cloud import utils diff --git a/src/coldfront_plugin_cloud/tests/unit/test_register_default_quotas.py b/src/coldfront_plugin_cloud/tests/unit/test_register_default_quotas.py new file mode 100644 index 00000000..d82c8dd5 --- /dev/null +++ b/src/coldfront_plugin_cloud/tests/unit/test_register_default_quotas.py @@ -0,0 +1,112 @@ +import json + +from django.core.management import call_command + +from coldfront_plugin_cloud.tests.base import TestBase +from coldfront_plugin_cloud import attributes + + +class TestRegisterDefaultQuotas(TestBase): + """Unit tests for the ``register_default_quotas`` management command. + + This command is intended to be idempotent. The first invocation should + populate the ``Available Quota Resources`` attribute on OpenShift and + OpenStack resources, and a second invocation should make no changes. + """ + + def test_register_default_quotas_idempotent(self): + # create one of each resource type + openshift_resource = self.new_openshift_resource(name="test-oshift") + openstack_resource = self.new_openstack_resource(name="test-osstack") + + call_command("register_default_quotas", apply=True) + + def load_quotas(resource): + val = resource.get_attribute(attributes.RESOURCE_QUOTA_RESOURCES) + self.assertIsNotNone(val, "quota attribute should be defined") + return json.loads(val) + + openshift_quota_dict = load_quotas(openshift_resource) + openstack_quota_dict = load_quotas(openstack_resource) + + # verify that each expected display name is present + expected_openshift_keys = { + attributes.QUOTA_LIMITS_CPU, + attributes.QUOTA_LIMITS_MEMORY, + attributes.QUOTA_LIMITS_EPHEMERAL_STORAGE_GB, + attributes.QUOTA_PVC, + attributes.QUOTA_REQUESTS_NESE_STORAGE, + attributes.QUOTA_REQUESTS_GPU, + } + self.assertEqual( + set(openshift_quota_dict.keys()), + expected_openshift_keys, + "OpenShift resource should have exactly the default quotas", + ) + + expected_openstack_keys = { + attributes.QUOTA_INSTANCES, + attributes.QUOTA_VCPU, + attributes.QUOTA_RAM, + attributes.QUOTA_VOLUMES, + attributes.QUOTA_VOLUMES_GB, + attributes.QUOTA_FLOATING_IPS, + attributes.QUOTA_OBJECT_GB, + } + self.assertEqual( + set(openstack_quota_dict.keys()), + expected_openstack_keys, + "OpenStack resource should have exactly the default quotas", + ) + + # spot-check a few of the fields to ensure values are correct + osh_cpu = openshift_quota_dict[attributes.QUOTA_LIMITS_CPU] + self.assertEqual(osh_cpu["quota_label"], "limits.cpu") + self.assertEqual(osh_cpu["multiplier"], 1) + + osst_ram = openstack_quota_dict[attributes.QUOTA_RAM] + self.assertEqual(osst_ram["quota_label"], "compute.ram") + self.assertEqual(osst_ram["multiplier"], 4096) + + # run the command again; since quotas already exist nothing should + call_command("register_default_quotas", apply=True) + + openshift_after = load_quotas(openshift_resource) + openstack_after = load_quotas(openstack_resource) + + self.assertEqual( + openshift_quota_dict, + openshift_after, + "Repeated invocation should not mutate OpenShift quotas", + ) + self.assertEqual( + openstack_quota_dict, + openstack_after, + "Repeated invocation should not mutate OpenStack quotas", + ) + + def test_register_default_quotas_with_existing_quota(self): + resource = self.new_openshift_resource(name="existing-quota") + call_command( + "add_quota_to_resource", + display_name=attributes.QUOTA_LIMITS_CPU, + resource_name=resource.name, + quota_label="limits.cpu", + multiplier=1, + ) + + # running the migration should detect that the attribute exists and skip + call_command("register_default_quotas", apply=True) + + # only the manually added quota should remain + val = resource.get_attribute(attributes.RESOURCE_QUOTA_RESOURCES) + quotas = json.loads(val) + self.assertEqual( + set(quotas.keys()), + {attributes.QUOTA_LIMITS_CPU}, + "Resource should only have the pre-existing CPU quota", + ) + self.assertEqual( + quotas[attributes.QUOTA_LIMITS_CPU]["quota_label"], "limits.cpu" + ) + self.assertEqual(quotas[attributes.QUOTA_LIMITS_CPU]["multiplier"], 1) diff --git a/src/coldfront_plugin_cloud/tests/unit/test_usage_models.py b/src/coldfront_plugin_cloud/tests/unit/test_usage_models.py index d1f3e81f..bf5f11da 100644 --- a/src/coldfront_plugin_cloud/tests/unit/test_usage_models.py +++ b/src/coldfront_plugin_cloud/tests/unit/test_usage_models.py @@ -1,7 +1,7 @@ from decimal import Decimal from pydantic import ValidationError -from coldfront_plugin_cloud import usage_models +from coldfront_plugin_cloud.models import usage_models from coldfront_plugin_cloud.tests import base