diff --git a/coral_credits/api/db_utils.py b/coral_credits/api/db_utils.py index a7ef199..edac680 100644 --- a/coral_credits/api/db_utils.py +++ b/coral_credits/api/db_utils.py @@ -1,4 +1,5 @@ import logging +import uuid from django.shortcuts import get_object_or_404 from django.utils import timezone @@ -8,13 +9,24 @@ LOG = logging.getLogger(__name__) -def get_current_lease(current_lease): - current_consumer = get_object_or_404( - models.Consumer, consumer_uuid=current_lease.id - ) - current_resource_requests = models.CreditAllocationResource.objects.filter( - consumer=current_consumer, - ) +def get_current_lease(current_lease_required, context, current_lease): + if current_lease_required: + current_consumer = get_object_or_404( + models.Consumer, consumer_uuid=current_lease.id + ) + current_resource_requests = models.ResourceConsumptionRecord.objects.filter( + consumer=current_consumer + ) + LOG.info( + f"User {context.user_id} requested an update to lease " + f"{current_lease.id}." + ) + LOG.info(f"Current lease resource requests: {current_resource_requests}") + + else: + current_consumer = None + current_resource_requests = None + return current_consumer, current_resource_requests @@ -76,6 +88,9 @@ def get_all_credit_allocations(resource_provider_account): account=resource_provider_account.account, start__lte=now, end__gte=now ).order_by("pk") + if not credit_allocations.exists(): + raise models.CreditAllocation.DoesNotExist + return credit_allocations @@ -158,7 +173,6 @@ def get_resource_requests(lease, current_resource_requests=None): } """ resource_requests = {} - for ( resource_type, amount, @@ -174,6 +188,15 @@ def get_resource_requests(lease, current_resource_requests=None): float(amount) * lease.duration, 1, ) + LOG.info( + f"for {resource_class} - current: {current_resource_requests}, " + f"new: {requested_resource_hours}" + ) + if (not current_resource_requests) and requested_resource_hours <= 0: + raise db_exceptions.ResourceRequestFormatError( + f"Invalid request: {requested_resource_hours} hours requested for " + f"{resource_class}." + ) if current_resource_requests: delta_resource_hours = calculate_delta_resource_hours( requested_resource_hours, @@ -207,8 +230,7 @@ def calculate_delta_resource_hours( resource_class=resource_class ).first() if current_resource_request: - current_resource_hours = current_resource_request.resource_hours - return requested_resource_hours - current_resource_hours + return requested_resource_hours - current_resource_request.resource_hours # Case: user requests a new resource return requested_resource_hours @@ -255,12 +277,19 @@ def check_credit_balance(credit_allocations, resource_requests): def spend_credits( - lease, resource_provider_account, context, resource_requests, credit_allocations + lease, + resource_provider_account, + context, + resource_requests, + credit_allocations, + current_consumer, + current_resource_requests, ): - + # We use a temporary UUID to avoid integrity errors + # Whilst we create the new ResourceConsumptionRecords. consumer = models.Consumer.objects.create( consumer_ref=lease.name, - consumer_uuid=lease.id, + consumer_uuid=uuid.uuid4(), resource_provider_account=resource_provider_account, user_ref=context.user_id, start=lease.start_date, @@ -268,18 +297,36 @@ def spend_credits( ) for resource_class in resource_requests: + if current_resource_requests: + current_resource_hours = ( + current_resource_requests.filter(resource_class=resource_class) + .first() + .resource_hours + ) + else: + current_resource_hours = 0 + models.ResourceConsumptionRecord.objects.create( consumer=consumer, resource_class=resource_class, - resource_hours=resource_requests[resource_class], + resource_hours=current_resource_hours + resource_requests[resource_class], ) # Subtract expenditure from CreditAllocationResource + # Or add, if the update delta is < 0 credit_allocations[resource_class].resource_hours = ( credit_allocations[resource_class].resource_hours - resource_requests[resource_class] ) credit_allocations[resource_class].save() + if current_consumer: + # We have CASCADE behaviour for ResourceConsumptionRecords + # Also we roll back all db transactions if the final check fails + current_consumer.delete() + # Now we set the real ID + consumer.consumer_uuid = lease.id + consumer.save() + def create_credit_resource_allocations(credit_allocation, resource_allocations): """Allocates resource credits to a given credit allocation. diff --git a/coral_credits/api/models.py b/coral_credits/api/models.py index 6046e35..8da0e32 100644 --- a/coral_credits/api/models.py +++ b/coral_credits/api/models.py @@ -47,7 +47,10 @@ class Meta: ) def __str__(self) -> str: - return f"{self.project_id} for {self.account} in {self.provider}" + return ( + f"Project ID:{self.project_id} for Account:{self.account} " + f"in Provider:{self.provider}" + ) class CreditAllocation(models.Model): @@ -68,7 +71,7 @@ class Meta: ) def __str__(self) -> str: - return f"{self.account} from {self.start}" + return f"{self.account} - {self.start}" class CreditAllocationResource(models.Model): @@ -89,7 +92,10 @@ class Meta: ordering = ("allocation__start",) def __str__(self) -> str: - return f"{self.resource_class} for {self.allocation}" + return ( + f"{self.resource_hours} hours allocated for {self.resource_class} " + f"from {self.allocation}" + ) class Consumer(models.Model): @@ -117,7 +123,10 @@ class Meta: ) def __str__(self) -> str: - return f"{self.consumer_ref}@{self.resource_provider_account}" + return ( + f"consumer ref:{self.consumer_ref} with " + f"id:{self.consumer_uuid}@{self.resource_provider_account}" + ) class ResourceConsumptionRecord(models.Model): @@ -137,4 +146,4 @@ class Meta: ordering = ("consumer__start",) def __str__(self) -> str: - return f"{self.consumer} from {self.resource_class}" + return f"{self.resource_class}:{self.resource_hours} hours for {self.consumer}" diff --git a/coral_credits/api/tests/conftest.py b/coral_credits/api/tests/conftest.py index 454c7f8..ffaf0e2 100644 --- a/coral_credits/api/tests/conftest.py +++ b/coral_credits/api/tests/conftest.py @@ -1,5 +1,7 @@ +import copy from datetime import datetime, timedelta +from django.apps import apps from django.contrib.auth.models import User from django.utils.timezone import make_aware import pytest @@ -10,10 +12,14 @@ def pytest_configure(config): + config.LEASE_NAME = "my_new_lease" + config.LEASE_ID = "e96b5a17-ada0-4034-a5ea-34db024b8e04" config.PROJECT_ID = "20354d7a-e4fe-47af-8ff6-187bca92f3f9" config.USER_REF = "caa8b54a-eb5e-4134-8ae2-a3946a428ec7" config.START_DATE = make_aware(datetime.now()) config.END_DATE = config.START_DATE + timedelta(days=1) + config.END_EARLY_DATE = config.START_DATE + timedelta(days=0.75) + config.END_LATE_DATE = config.START_DATE + timedelta(days=1.5) # Get auth token @@ -80,18 +86,190 @@ def _create_credit_allocation_resources( vcpu_allocation = models.CreditAllocationResource.objects.create( allocation=credit_allocation, resource_class=vcpu, - resource_hours=allocation_hours["vcpu"], + resource_hours=allocation_hours["VCPU"], ) memory_allocation = models.CreditAllocationResource.objects.create( allocation=credit_allocation, resource_class=memory, - resource_hours=allocation_hours["memory"], + resource_hours=allocation_hours["MEMORY_MB"], ) disk_allocation = models.CreditAllocationResource.objects.create( allocation=credit_allocation, resource_class=disk, - resource_hours=allocation_hours["disk"], + resource_hours=allocation_hours["DISK_GB"], ) return (vcpu_allocation, memory_allocation, disk_allocation) return _create_credit_allocation_resources + + +##### +# POST-TEST +##### + + +@pytest.fixture(autouse=True) +def print_db_state(): + """We output the state of the database after a test.""" + # pre-test: + yield # this is where the testing happens + + # post-test: + print("\n----- Database State -----") + + # Get all models from your app + app_models = apps.get_app_config("api").get_models() + + for model in app_models: + print(f"\n{model.__name__}:") + for instance in model.objects.all(): + print(f" - {instance}") + + print("\n--------------------------") + + +##### +# REQUEST DATA +##### + + +@pytest.fixture +def base_request_data(request): + return { + "context": { + "user_id": request.config.USER_REF, + "project_id": request.config.PROJECT_ID, + "auth_url": "https://api.example.com:5000/v3", + "region_name": "RegionOne", + }, + "lease": { + "id": request.config.LEASE_ID, + "name": request.config.LEASE_NAME, + "start_date": request.config.START_DATE.isoformat(), + "end_date": request.config.END_DATE.isoformat(), + "before_end_date": None, + "reservations": [], + }, + } + + +@pytest.fixture +def flavor_request_data(base_request_data): + flavor_data = { + "lease": { + "reservations": [ + { + "amount": 2, + "flavor_id": "e26a4241-b83d-4516-8e0e-8ce2665d1966", + "resource_type": "flavor:instance", + "affinity": "None", + "allocations": [], + } + ], + "resource_requests": { + "DISK_GB": 35, + "MEMORY_MB": 1000, + "VCPU": 4, + }, + }, + } + return deep_merge(base_request_data, flavor_data) + + +@pytest.fixture +def flavor_extend_current_request_data(flavor_request_data, request): + delete_request_data = {"current_lease": copy.deepcopy(flavor_request_data["lease"])} + delete_request_data["lease"] = { + "end_date": request.config.END_LATE_DATE.isoformat() + } + return deep_merge(flavor_request_data, delete_request_data) + + +@pytest.fixture +def flavor_delete_current_request_data(flavor_request_data, request): + delete_request_data = {"current_lease": copy.deepcopy(flavor_request_data["lease"])} + delete_request_data["lease"] = { + "end_date": request.config.END_EARLY_DATE.isoformat() + } + return deep_merge(flavor_request_data, delete_request_data) + + +@pytest.fixture +def flavor_delete_upcoming_request_data(flavor_request_data, request): + delete_request_data = {"current_lease": copy.deepcopy(flavor_request_data["lease"])} + delete_request_data["lease"] = {"end_date": request.config.START_DATE.isoformat()} + return deep_merge(flavor_request_data, delete_request_data) + + +@pytest.fixture +def zero_hours_request_data(flavor_request_data, request): + zero_hours_request_data = { + "lease": {"end_date": request.config.START_DATE.isoformat()} + } + return deep_merge(flavor_request_data, zero_hours_request_data) + + +@pytest.fixture +def negative_hours_request_data(flavor_request_data, request): + zero_hours_request_data = { + "lease": { + "end_date": (request.config.START_DATE - timedelta(days=1)).isoformat() + } + } + return deep_merge(flavor_request_data, zero_hours_request_data) + + +@pytest.fixture +def physical_request_data(base_request_data): + physical_request_data = { + "lease": { + "reservations": [ + { + "resource_type": "physical:host", + "min": 1, + "max": 2, + "hypervisor_properties": "", + "resource_properties": "", + "allocations": [], + } + ], + }, + } + return deep_merge(base_request_data, physical_request_data) + + +@pytest.fixture +def virtual_request_data(base_request_data): + virtual_request_data = { + "lease": { + "reservations": [ + { + "resource_type": "virtual:instance", + "amount": 1, + "vcpus": 1, + "memory_mb": 1, + "disk_gb": 0, + "affinity": "None", + "resource_properties": "", + "allocations": [], + } + ], + }, + } + return deep_merge(base_request_data, virtual_request_data) + + +def deep_merge(defaults, overrides=None): + """Recursively merge two dictionaries.""" + result = defaults.copy() + if overrides: + for key, value in overrides.items(): + if ( + key in result + and isinstance(result[key], dict) + and isinstance(value, dict) + ): + result[key] = deep_merge(result[key], value) + else: + result[key] = value + return result diff --git a/coral_credits/api/tests/consumer_tests.py b/coral_credits/api/tests/consumer_tests.py index 3df7e4d..0ce823f 100644 --- a/coral_credits/api/tests/consumer_tests.py +++ b/coral_credits/api/tests/consumer_tests.py @@ -8,108 +8,49 @@ import coral_credits.api.models as models -# TODO(tylerchristie): maybe make/use some kind of request factory # TODO(tylerchristie): check and commit tests -@pytest.fixture -def flavor_request_data(request): - return { - "context": { - "user_id": request.config.USER_REF, - "project_id": request.config.PROJECT_ID, - "auth_url": "https://api.example.com:5000/v3", - "region_name": "RegionOne", - }, - "lease": { - # "id": "e96b5a17-ada0-4034-a5ea-34db024b8e04", - "name": "my_new_lease", - "start_date": request.config.START_DATE.isoformat(), - "end_date": request.config.END_DATE.isoformat(), - "before_end_date": None, - "reservations": [ - { - "amount": 2, - "flavor_id": "e26a4241-b83d-4516-8e0e-8ce2665d1966", - "resource_type": "flavor:instance", - "affinity": "None", - "allocations": [], - } - ], - "resource_requests": { - "DISK_GB": 35, - "MEMORY_MB": 1000, - "VCPU": 4, - }, - }, - } - - -@pytest.fixture -def physical_request_data(request): - return { - "context": { - "user_id": request.config.USER_REF, - "project_id": request.config.PROJECT_ID, - "auth_url": "https://api.example.com:5000/v3", - "region_name": "RegionOne", - }, - "lease": { - "id": "e96b5a17-ada0-4034-a5ea-34db024b8e04", - "name": "my_new_lease", - "start_date": request.config.START_DATE.isoformat(), - "end_date": request.config.END_DATE.isoformat(), - "before_end_date": None, - "reservations": [ - { - "resource_type": "physical:host", - "min": 1, - "max": 2, - "hypervisor_properties": "", - "resource_properties": "", - "allocations": [], - } - ], - }, - } - - -@pytest.fixture -def virtual_request_data(request): - return { - "context": { - "user_id": request.config.USER_REF, - "project_id": request.config.PROJECT_ID, - "auth_url": "https://api.example.com:5000/v3", - "region_name": "RegionOne", - }, - "lease": { - "id": "e96b5a17-ada0-4034-a5ea-34db024b8e04", - "name": "my_new_lease", - "start_date": request.config.START_DATE.isoformat(), - "end_date": request.config.END_DATE.isoformat(), - "before_end_date": None, - "reservations": [ - { - "resource_type": "virtual:instance", - "amount": 1, - "vcpus": 1, - "memory_mb": 1, - "disk_gb": 0, - "affinity": "None", - "resource_properties": "", - "allocations": [], - } - ], - }, - } +def consumer_request(url, api_client, request_data, expected_response): + response = api_client.post( + url, + data=json.dumps(request_data), + content_type="application/json", + secure=True, + ) + + assert response.status_code == expected_response, ( + f"Expected {expected_response}. " + f"Actual status {response.status_code}. " + f"Response text {response.content}" + ) + + return response + + +def consumer_create_request(api_client, request_data, expected_response): + return consumer_request( + reverse("resource-request-create-consumer"), + api_client, + request_data, + expected_response, + ) + + +def consumer_update_request(api_client, request_data, expected_response): + return consumer_request( + reverse("resource-request-update-consumer"), + api_client, + request_data, + expected_response, + ) @pytest.mark.parametrize( "allocation_hours,request_data", [ ( - {"vcpu": 96.0, "memory": 24000.0, "disk": 840.0}, + {"VCPU": 96.0, "MEMORY_MB": 24000.0, "DISK_GB": 840.0}, lazy_fixture("flavor_request_data"), ), ], @@ -125,67 +66,208 @@ def test_flavor_create_request( allocation_hours, request, # contains pytest global vars ): - START_DATE = request.config.START_DATE - END_DATE = request.config.END_DATE - USER_REF = request.config.USER_REF - - credit_allocation_resources = create_credit_allocation_resources( + # Allocate resource credit + create_credit_allocation_resources( credit_allocation, resource_classes, allocation_hours ) - url = reverse("resource-request-list") - response = api_client.post( - url, - data=json.dumps(request_data), - content_type="application/json", - secure=True, - ) - - assert response.status_code == status.HTTP_204_NO_CONTENT, ( - f"Expected {status.HTTP_204_NO_CONTENT}. " - f"Actual status {response.status_code}. " - f"Response text {response.content}" - ) + # Create + consumer_create_request(api_client, request_data, status.HTTP_204_NO_CONTENT) - new_consumer = models.Consumer.objects.filter(consumer_ref="my_new_lease").first() + # Find consumer and assert fields are correct + new_consumer = models.Consumer.objects.filter( + consumer_ref=request.config.LEASE_NAME + ).first() assert new_consumer is not None assert new_consumer.resource_provider_account == resource_provider_account - assert new_consumer.user_ref == uuid.UUID(USER_REF) - assert new_consumer.start == START_DATE - assert new_consumer.end == END_DATE + assert new_consumer.user_ref == uuid.UUID(request.config.USER_REF) + assert new_consumer.start == request.config.START_DATE + assert new_consumer.end == request.config.END_DATE + # Check credit has been subtracted for c in models.CreditAllocationResource.objects.all(): assert ( c.resource_hours == 0 ), f"CreditAllocationResource for {c.resource_class.name} is not depleted" - vcpu, memory, disk = resource_classes - rcr_vcpu = models.ResourceConsumptionRecord.objects.get( - consumer=new_consumer, resource_class=vcpu + # Check consumption records are correct + for resource_class in resource_classes: + rcr = models.ResourceConsumptionRecord.objects.get( + consumer=new_consumer, resource_class=resource_class.id + ) + assert rcr.resource_hours == allocation_hours[resource_class.name] + + +@pytest.mark.parametrize( + "allocation_hours,request_data,delete_request_data", + [ + ( + {"VCPU": 96.0, "MEMORY_MB": 24000.0, "DISK_GB": 840.0}, + lazy_fixture("flavor_request_data"), + lazy_fixture("flavor_delete_upcoming_request_data"), + ), + ], +) +@pytest.mark.django_db +def test_flavor_delete_upcoming_request( + resource_classes, + credit_allocation, + create_credit_allocation_resources, + resource_provider_account, + api_client, + request_data, + delete_request_data, + allocation_hours, + request, # contains pytest global vars +): + # Allocate resource credit + create_credit_allocation_resources( + credit_allocation, resource_classes, allocation_hours ) - assert rcr_vcpu.resource_hours == 96.0 - rcr_memory = models.ResourceConsumptionRecord.objects.get( - consumer=new_consumer, resource_class=memory + # Create + consumer_create_request(api_client, request_data, status.HTTP_204_NO_CONTENT) + + # Delete + consumer_update_request(api_client, delete_request_data, status.HTTP_204_NO_CONTENT) + + # Find consumer and check duration is 0. + new_consumer = models.Consumer.objects.filter( + consumer_ref=request.config.LEASE_NAME + ).first() + assert new_consumer is not None + assert new_consumer.end == request.config.START_DATE + + # Check our original allocations are intact. + for resource_class in resource_classes: + c = models.CreditAllocationResource.objects.filter( + resource_class=resource_class.id + ).first() + assert c.resource_hours == allocation_hours[resource_class.name], ( + f"CreditAllocationResource for {c.resource_class.name} has changed, " + f"new amount: {c.resource_hours}" + ) + + +@pytest.mark.parametrize( + "allocation_hours,request_data,delete_request_data", + [ + ( + {"VCPU": 96.0, "MEMORY_MB": 24000.0, "DISK_GB": 840.0}, + lazy_fixture("flavor_request_data"), + lazy_fixture("flavor_delete_current_request_data"), + ), + ], +) +@pytest.mark.django_db +def test_flavor_delete_currently_active_request( + resource_classes, + credit_allocation, + create_credit_allocation_resources, + resource_provider_account, + api_client, + request_data, + delete_request_data, + allocation_hours, + request, # contains pytest global vars +): + # Allocate resource credit + create_credit_allocation_resources( + credit_allocation, resource_classes, allocation_hours ) - assert rcr_memory.resource_hours == 24000.0 - rcr_disk = models.ResourceConsumptionRecord.objects.get( - consumer=new_consumer, resource_class=disk + # Create + consumer_create_request(api_client, request_data, status.HTTP_204_NO_CONTENT) + + # Delete + consumer_update_request(api_client, delete_request_data, status.HTTP_204_NO_CONTENT) + + # Find consumer and check end date is changed. + new_consumer = models.Consumer.objects.filter( + consumer_ref=request.config.LEASE_NAME + ).first() + assert new_consumer is not None + assert new_consumer.end == request.config.END_EARLY_DATE + + # END_EARLY_DATE is 3/4 the duration of the original allocation. + + # Check our allocations are correct. + for resource_class in resource_classes: + c = models.CreditAllocationResource.objects.filter( + resource_class=resource_class.id + ).first() + assert c.resource_hours == allocation_hours[resource_class.name] * 0.25 + + # Check consumption records are correct + for resource_class in resource_classes: + rcr = models.ResourceConsumptionRecord.objects.get( + consumer=new_consumer, resource_class=resource_class.id + ) + assert rcr.resource_hours == allocation_hours[resource_class.name] * 0.75 + + +@pytest.mark.parametrize( + "allocation_hours,request_data,delete_request_data", + [ + ( + {"VCPU": 144, "MEMORY_MB": 36000.0, "DISK_GB": 1260.0}, + lazy_fixture("flavor_request_data"), + lazy_fixture("flavor_extend_current_request_data"), + ), + ], +) +@pytest.mark.django_db +def test_flavor_extend_currently_active_request( + resource_classes, + credit_allocation, + create_credit_allocation_resources, + resource_provider_account, + api_client, + request_data, + delete_request_data, + allocation_hours, + request, # contains pytest global vars +): + # Allocate resource credit + create_credit_allocation_resources( + credit_allocation, resource_classes, allocation_hours ) - assert rcr_disk.resource_hours == 840.0 - vcpu_allocation, memory_allocation, disk_allocation = credit_allocation_resources - assert vcpu_allocation.resource_hours == rcr_vcpu.resource_hours - assert memory_allocation.resource_hours == rcr_memory.resource_hours - assert disk_allocation.resource_hours == rcr_disk.resource_hours + # Create + consumer_create_request(api_client, request_data, status.HTTP_204_NO_CONTENT) + + # Extend + consumer_update_request(api_client, delete_request_data, status.HTTP_204_NO_CONTENT) + + # Find consumer and check end date is changed. + new_consumer = models.Consumer.objects.filter( + consumer_ref=request.config.LEASE_NAME + ).first() + assert new_consumer is not None + assert new_consumer.end == request.config.END_LATE_DATE + + # END_LATE_DATE is 1.5x the duration of the original allocation. + + # Check our allocations are correct. + for resource_class in resource_classes: + c = models.CreditAllocationResource.objects.filter( + resource_class=resource_class.id + ).first() + assert c.resource_hours == 0 + + # Check consumption records are correct + for resource_class in resource_classes: + rcr = models.ResourceConsumptionRecord.objects.get( + consumer=new_consumer, resource_class=resource_class.id + ) + assert rcr.resource_hours == allocation_hours[resource_class.name] @pytest.mark.parametrize( "allocation_hours, request_data", [ ( - {"vcpu": 10.0, "memory": 1000.0, "disk": 100.0}, + {"VCPU": 10.0, "MEMORY_MB": 1000.0, "DISK_GB": 100.0}, lazy_fixture("flavor_request_data"), ), ], @@ -205,19 +287,7 @@ def test_insufficient_credits_create_request( credit_allocation, resource_classes, allocation_hours ) - url = reverse("resource-request-list") - response = api_client.post( - url, - data=json.dumps(request_data), - content_type="application/json", - secure=True, - ) - - assert response.status_code == status.HTTP_403_FORBIDDEN, ( - f"Expected {status.HTTP_403_FORBIDDEN}. " - f"Actual status {response.status_code}. " - f"Response text {response.content}" - ) + consumer_create_request(api_client, request_data, status.HTTP_403_FORBIDDEN) # TODO(tylerchristie): assert that allocations have their original values assert not models.Consumer.objects.filter(consumer_ref="my_new_lease").exists() @@ -240,16 +310,38 @@ def test_invalid_blazar_resource_type_create_request( api_client, request_data, ): - url = reverse("resource-request-list") - response = api_client.post( - url, - data=json.dumps(request_data), - content_type="application/json", - secure=True, + consumer_create_request(api_client, request_data, status.HTTP_400_BAD_REQUEST) + + +@pytest.mark.parametrize( + "allocation_hours,request_data", + [ + ( + {"VCPU": 96.0, "MEMORY_MB": 24000.0, "DISK_GB": 840.0}, + lazy_fixture("zero_hours_request_data"), + ), + ( + {"VCPU": 96.0, "MEMORY_MB": 24000.0, "DISK_GB": 840.0}, + lazy_fixture("negative_hours_request_data"), + ), + ], +) +@pytest.mark.django_db +def test_invalid_duration_create_request( + resource_classes, + credit_allocation, + create_credit_allocation_resources, + resource_provider_account, + api_client, + request_data, + allocation_hours, +): + create_credit_allocation_resources( + credit_allocation, resource_classes, allocation_hours ) - assert response.status_code == status.HTTP_400_BAD_REQUEST, ( - f"Expected {status.HTTP_400_BAD_REQUEST}. " - f"Actual status {response.status_code}. " - f"Response text {response.content}" + response = consumer_create_request( + api_client, request_data, status.HTTP_403_FORBIDDEN ) + + print(response.content) diff --git a/coral_credits/api/views.py b/coral_credits/api/views.py index b12623d..4c60871 100644 --- a/coral_credits/api/views.py +++ b/coral_credits/api/views.py @@ -1,13 +1,17 @@ +import logging import uuid from django.db import transaction from django.db.utils import IntegrityError from django.shortcuts import get_object_or_404 from rest_framework import permissions, status, viewsets +from rest_framework.decorators import action from rest_framework.response import Response from coral_credits.api import db_exceptions, db_utils, models, serializers +LOG = logging.getLogger(__name__) + class CreditAllocationViewSet(viewsets.ModelViewSet): queryset = models.CreditAllocation.objects.all() @@ -138,15 +142,19 @@ class ConsumerViewSet(viewsets.ModelViewSet): queryset = models.Consumer.objects.all() permission_classes = [permissions.IsAuthenticated] - def create(self, request): + @action(detail=False, methods=["post"], url_path="create") + def create_consumer(self, request): return self._create_or_update(request) - def update(self, request): + @action(detail=False, methods=["post"], url_path="update") + def update_consumer(self, request): return self._create_or_update(request, current_lease_required=True) + @action(detail=False, methods=["post"], url_path="check-create") def check_create(self, request): return self._create_or_update(request, dry_run=True) + @action(detail=False, methods=["post"], url_path="check-update") def check_update(self, request): return self._create_or_update( request, current_lease_required=True, dry_run=True @@ -159,31 +167,35 @@ def _create_or_update(self, request, current_lease_required=False, dry_run=False see consumer_tests.py for example requests. """ # TODO(tylerchristie): remove when blazar has commit hook. - request.data["lease"]["id"] = uuid.uuid4() + if "id" not in request.data["lease"]: + LOG.warning("Creating fake UUID for lease.") + request.data["lease"]["id"] = uuid.uuid4() + context, lease, current_lease = self._validate_request( request, current_lease_required ) - if current_lease_required: - try: - current_consumer, current_resource_requests = ( - db_utils.get_current_lease(current_lease) - ) - except models.Consumer.DoesNotExist: - return _http_403_forbidden("No matching record found for current lease") + LOG.info( + f"Incoming Request - Context: {context}, Lease: {lease}, " + f"Current Lease: {current_lease}" + ) + # Getting required data try: + current_consumer, current_resource_requests = db_utils.get_current_lease( + current_lease_required, context, current_lease + ) resource_provider_account = db_utils.get_resource_provider_account( context.project_id ) + credit_allocations = db_utils.get_all_credit_allocations( + resource_provider_account + ) + except models.Consumer.DoesNotExist: + return _http_403_forbidden("No matching record found for current lease") except models.ResourceProviderAccount.DoesNotExist: return _http_403_forbidden("No matching ResourceProviderAccount found") - - credit_allocations = db_utils.get_all_credit_allocations( - resource_provider_account - ) - - if not credit_allocations.exists(): + except models.CreditAllocation.DoesNotExist: return _http_403_forbidden("No active CreditAllocation found") # Check resource credit availability (first check) @@ -212,11 +224,6 @@ def _create_or_update(self, request, current_lease_required=False, dry_run=False if not dry_run: # Account has sufficient credits at time of database query, # so we allocate resources. - # Update scenario: - if current_lease_required: - # We have CASCADE behaviour for ResourceConsumptionRecords - # Also we roll back all db transactions if the final check fails - current_consumer.delete() try: db_utils.spend_credits( lease, @@ -224,6 +231,8 @@ def _create_or_update(self, request, current_lease_required=False, dry_run=False context, resource_requests, allocation_hours, + current_consumer, + current_resource_requests, ) except IntegrityError as e: # Lease ID is not unique @@ -231,7 +240,9 @@ def _create_or_update(self, request, current_lease_required=False, dry_run=False return _http_403_forbidden(repr(e)) # Final check + # Rollback here if credit accounts fall below 0. db_utils.check_credit_balance(credit_allocations, resource_requests) + return _http_204_no_content("Consumer and resources requested successfully") return _http_204_no_content( @@ -243,11 +254,13 @@ def _validate_request(self, request, current_lease_required): data=request.data, current_lease_required=current_lease_required ) resource_request.is_valid(raise_exception=True) - consumer_request = resource_request.create(resource_request.validated_data) + consumer_create_request = resource_request.create( + resource_request.validated_data + ) return ( - consumer_request.context, - consumer_request.lease, - consumer_request.current_lease, + consumer_create_request.context, + consumer_create_request.lease, + consumer_create_request.current_lease, ) diff --git a/tools/functional_test.sh b/tools/functional_test.sh index bce7fa9..8c2a156 100755 --- a/tools/functional_test.sh +++ b/tools/functional_test.sh @@ -227,7 +227,7 @@ RESPONSE=$(curl -s -w "%{http_code}" -X POST -H "$AUTH_HEADER" -H "$CONTENT_TYPE } } }" \ - http://$SITE:$PORT/consumer/) + http://$SITE:$PORT/consumer/create/) if [ "$RESPONSE" -eq 204 ]; then echo "All tests completed."