diff --git a/.tiltignore b/.tiltignore new file mode 100644 index 0000000..109c001 --- /dev/null +++ b/.tiltignore @@ -0,0 +1 @@ +tofu/ \ No newline at end of file diff --git a/coral_credits/api/db_exceptions.py b/coral_credits/api/db_exceptions.py index 3adde29..46e14e5 100644 --- a/coral_credits/api/db_exceptions.py +++ b/coral_credits/api/db_exceptions.py @@ -20,3 +20,9 @@ class NoResourceClass(Exception): """Raised when there is no resource class matching the query""" pass + + +class ActiveConsumersInAllocation(Exception): + """Raised when trying to delete an allocation with active consumers""" + + pass diff --git a/coral_credits/api/db_utils.py b/coral_credits/api/db_utils.py index 4111cab..d8b6c02 100644 --- a/coral_credits/api/db_utils.py +++ b/coral_credits/api/db_utils.py @@ -71,12 +71,11 @@ def get_all_active_reservations(resource_provider_account): def get_credit_allocation(id): now = timezone.now() - try: - credit_allocation = models.CreditAllocation.objects.filter( - id=id, start__lte=now, end__gte=now - ).first() - except models.CreditAllocation.DoesNotExist: + + credit_allocation = models.CreditAllocation.objects.filter(id=id).first() + if credit_allocation == None: raise db_exceptions.NoCreditAllocation("Invalid allocation_id") + return credit_allocation @@ -343,11 +342,15 @@ def create_credit_resource_allocations(credit_allocation, resource_allocations): car, created = models.CreditAllocationResource.objects.get_or_create( allocation=credit_allocation, resource_class=resource_class, - defaults={"resource_hours": resource_hours}, + defaults={"resource_hours": resource_hours, "allocated_resource_hours": resource_hours}, ) # If exists, update: if not created: - car.resource_hours += resource_hours + newly_allocated_resource_hours = resource_hours + car.resource_hours += newly_allocated_resource_hours - car.allocated_resource_hours + if car.resource_hours < 0: + raise db_exceptions.InsufficientCredits("Cannot set credits to fewer than currently consumed") + car.allocated_resource_hours = newly_allocated_resource_hours car.save() # Refresh from db to get the updated resource_hours diff --git a/coral_credits/api/models.py b/coral_credits/api/models.py index 8da0e32..0e43e31 100644 --- a/coral_credits/api/models.py +++ b/coral_credits/api/models.py @@ -82,6 +82,7 @@ class CreditAllocationResource(models.Model): ResourceClass, on_delete=models.DO_NOTHING, related_name="+" ) resource_hours = models.FloatField() + allocated_resource_hours = models.FloatField() created = models.DateTimeField(auto_now_add=True) class Meta: @@ -100,28 +101,15 @@ def __str__(self) -> str: class Consumer(models.Model): consumer_ref = models.CharField(max_length=200) - consumer_uuid = models.UUIDField() + consumer_uuid = models.UUIDField(unique=True) resource_provider_account = models.ForeignKey( - ResourceProviderAccount, on_delete=models.DO_NOTHING + ResourceProviderAccount, on_delete=models.SET_NULL, null=True ) user_ref = models.UUIDField() created = models.DateTimeField(auto_now_add=True) start = models.DateTimeField() end = models.DateTimeField() - class Meta: - # TODO(tylerchristie): allow either/or nullable? - # constraints = [ - # models.CheckConstraint( - # check=Q(consumer_ref=False) | Q(consumer_uuid=False), - # name='not_both_null' - # ) - # ] - unique_together = ( - "consumer_uuid", - "resource_provider_account", - ) - def __str__(self) -> str: return ( f"consumer ref:{self.consumer_ref} with " diff --git a/coral_credits/api/serializers.py b/coral_credits/api/serializers.py index 53e8603..355e933 100644 --- a/coral_credits/api/serializers.py +++ b/coral_credits/api/serializers.py @@ -46,7 +46,7 @@ class CreditAllocationResourceSerializer(serializers.ModelSerializer): class Meta: model = models.CreditAllocationResource - fields = ["resource_class", "resource_hours"] + fields = ["id", "resource_class", "resource_hours","allocated_resource_hours"] def to_representation(self, instance): """Pass the context to the ResourceClassSerializer""" @@ -73,12 +73,19 @@ class Meta: class Consumer(serializers.ModelSerializer): - resource_provider = ResourceProviderSerializer() + resource_provider_account = ResourceProviderAccountSerializer() resources = ResourceConsumptionRecord(many=True) class Meta: model = models.Consumer - fields = ["consumer_ref", "resource_provider", "start", "end", "resources"] + fields = [ + "id", + "consumer_ref", + "resource_provider_account", + "start", + "end", + "resources", + ] class ResourceRequestSerializer(serializers.Serializer): diff --git a/coral_credits/api/views.py b/coral_credits/api/views.py index 2c4fe48..87eddc0 100644 --- a/coral_credits/api/views.py +++ b/coral_credits/api/views.py @@ -16,18 +16,39 @@ LOG = logging.getLogger(__name__) +def destroy_if_no_active_consumers(linked_consumers_queryset, request, destroy_super): + + current_time = make_aware(datetime.now()) + for consumer in linked_consumers_queryset: + if current_time < consumer.end: + return _http_403_forbidden(repr(db_exceptions.ActiveConsumersInAllocation)) + else: + return destroy_super.destroy(request) + + class CreditAllocationViewSet(viewsets.ModelViewSet): queryset = models.CreditAllocation.objects.all() serializer_class = serializers.CreditAllocationSerializer - # permission_classes = [permissions.IsAuthenticated] + permission_classes = [permissions.IsAuthenticated] + + def destroy(self, request, pk=None): + allocation = get_object_or_404(self.queryset, pk=pk) + linked_consumers = models.Consumer.objects.filter( + resource_provider_account__account__pk=allocation.account.pk + ) + return destroy_if_no_active_consumers(linked_consumers, request, super()) class CreditAllocationResourceViewSet(viewsets.ModelViewSet): - queryset = models.CreditAllocationResource.objects.all() serializer_class = serializers.CreditAllocationResourceSerializer permission_classes = [permissions.IsAuthenticated] - def create(self, request, allocation_pk=None): + def get_queryset(self): + return models.CreditAllocationResource.objects.filter( + allocation__pk=self.kwargs["allocation_pk"] + ) + + def _create_update_credit_allocations(self, request, allocation_pk): """Allocate credits to a dictionary of resource classes. Example Request: @@ -58,7 +79,27 @@ def create(self, request, allocation_pk=None): serializer = serializers.CreditAllocationResourceSerializer( updated_allocations, many=True, context={"request": request} ) - return _http_200_ok(serializer.data) + # To allow for the allocation resource endpoint to operate as a single REST object + # with GET,POST,PATCH etc after creation of single item + # When creating with multiple resources a list of multiple entries is returned, + # each with their own unique ID + if len(serializer.data) == 1: + return Response(serializer.data[0], status=status.HTTP_201_CREATED) + else: + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def create(self, request, allocation_pk=None): + return self._create_update_credit_allocations(request, allocation_pk) + + def update(self, request, allocation_pk=None, pk=None): + return self._create_update_credit_allocations(request, allocation_pk) + + def destroy(self, request, allocation_pk=None, pk=None): + resource = get_object_or_404(self.get_queryset(), pk=pk) + linked_consumers = models.Consumer.objects.filter( + resource_provider_account__account__pk=resource.allocation.account.pk + ) + return destroy_if_no_active_consumers(linked_consumers, request, super()) def _validate_request(self, request): @@ -82,12 +123,26 @@ class ResourceProviderViewSet(viewsets.ModelViewSet): serializer_class = serializers.ResourceProviderSerializer permission_classes = [permissions.IsAuthenticated] + def destroy(self, request, pk=None): + rpa = get_object_or_404(self.queryset, pk=pk) + linked_consumers = models.Consumer.objects.filter( + resource_provider_account__pk=rpa.pk + ) + return destroy_if_no_active_consumers(linked_consumers, request, super()) + class ResourceProviderAccountViewSet(viewsets.ModelViewSet): queryset = models.ResourceProviderAccount.objects.all() serializer_class = serializers.ResourceProviderAccountSerializer permission_classes = [permissions.IsAuthenticated] + def destroy(self, request, pk=None): + provider = get_object_or_404(self.queryset, pk=pk) + linked_consumers = models.Consumer.objects.filter( + resource_provider_account__provider__pk=provider.pk + ) + return destroy_if_no_active_consumers(linked_consumers, request, super()) + class AccountViewSet(viewsets.ModelViewSet): queryset = models.CreditAccount.objects.all() @@ -105,13 +160,18 @@ def retrieve(self, request, pk=None): account_summary = serializer.data # TODO(johngarbutt) look for any during the above allocations + + # ISSUE (stuartc) this creates errors if objects are actually found when the account entry + # is retrieved. Unable to serialise the list. all_allocations_query = models.CreditAllocation.objects.filter(account__pk=pk) allocations = serializers.CreditAllocationSerializer( - all_allocations_query, many=True + all_allocations_query, many=True, context={"request": request} ) # TODO(johngarbutt) look for any during the above allocations - consumers_query = models.Consumer.objects.filter(account__pk=pk) + consumers_query = models.Consumer.objects.filter( + resource_provider_account__account__pk=pk + ) consumers = serializers.Consumer( consumers_query, many=True, context={"request": request} ) @@ -119,9 +179,19 @@ def retrieve(self, request, pk=None): account_summary["allocations"] = allocations.data account_summary["consumers"] = consumers.data + all_allocation_resources_query = models.CreditAllocationResource.objects.filter( + allocation__account__pk=pk + ) # add resource_hours_remaining... must be a better way! # TODO(johngarbut) we don't check the dates line up!! for allocation in account_summary["allocations"]: + resources_for_allocation_query = all_allocation_resources_query.filter( + allocation__id=allocation["id"] + ) + resources_for_allocation = serializers.CreditAllocationResourceSerializer( + resources_for_allocation_query, many=True, context={"request": request} + ) + allocation["resources"] = resources_for_allocation.data for resource_allocation in allocation["resources"]: if "resource_hours_remaining" not in resource_allocation: resource_allocation["resource_hours_remaining"] = ( @@ -140,12 +210,31 @@ def retrieve(self, request, pk=None): return Response(account_summary) + def destroy(self, request, pk=None): + account = get_object_or_404(self.queryset, pk=pk) + linked_consumers = models.Consumer.objects.filter( + resource_provider_account__account__pk=account.pk + ) + return destroy_if_no_active_consumers(linked_consumers, request, super()) + class ConsumerViewSet(viewsets.ModelViewSet): queryset = models.Consumer.objects.all() permission_classes = [permissions.IsAuthenticated] serializer_class = serializers.ConsumerRequestSerializer + # TODO: need to split the Consumer and ConsumerRequest logic really + def retrieve(self, request, pk=None): + consumer = get_object_or_404(self.queryset, pk=pk) + serializer = serializers.Consumer(consumer, context={"request": request}) + return Response(serializer.data) + + def list(self, request): + serializer = serializers.Consumer( + self.queryset, many=True, context={"request": request} + ) + return Response(serializer.data) + @action(detail=False, methods=["post"], url_path="create") def create_consumer(self, request): LOG.info(f"About to process create commit:\n{request.data}") diff --git a/coral_credits/urls.py b/coral_credits/urls.py index e98c574..76f01dc 100644 --- a/coral_credits/urls.py +++ b/coral_credits/urls.py @@ -24,20 +24,45 @@ from coral_credits.api import views -router = routers.DefaultRouter() +# setup to endpoints to support both with and without trailing slashes +# +router = routers.DefaultRouter(trailing_slash=False) router.register(r"resource_class", views.ResourceClassViewSet) +router.register( + r"resource_class/", views.ResourceClassViewSet, basename="resourceclassslash" +) router.register(r"resource_provider", views.ResourceProviderViewSet) +router.register( + r"resource_provider/", + views.ResourceProviderViewSet, + basename="resourceproviderslash", +) router.register(r"resource_provider_account", views.ResourceProviderAccountViewSet) +router.register( + r"resource_provider_account/", + views.ResourceProviderAccountViewSet, + basename="resourceprovideraccountslash", +) router.register(r"allocation", views.CreditAllocationViewSet) +router.register( + r"allocation/", views.CreditAllocationViewSet, basename="allocationslash" +) router.register(r"account", views.AccountViewSet, basename="creditaccount") +router.register(r"account/", views.AccountViewSet, basename="creditaccountslash") router.register(r"consumer", views.ConsumerViewSet, basename="resource-request") +router.register(r"consumer/", views.ConsumerViewSet, basename="resource-requestslash") allocation_router = routers.NestedSimpleRouter( - router, r"allocation", lookup="allocation" + router, r"allocation", lookup="allocation", trailing_slash=False ) allocation_router.register( r"resources", views.CreditAllocationResourceViewSet, basename="allocation-resource" ) +allocation_router.register( + r"resources/", + views.CreditAllocationResourceViewSet, + basename="allocation-resource-slash", +) def prometheus_metrics(request): diff --git a/requirements.txt b/requirements.txt index 3a379a0..7fc6a10 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,5 @@ prometheus_client==0.20.0 tzdata==2024.1 psycopg2-binary==2.9.9 whitenoise==6.9.0 +tofupy==1.1.1 +requests==2.32.5 diff --git a/tofu/.gitignore b/tofu/.gitignore new file mode 100644 index 0000000..c9b3eba --- /dev/null +++ b/tofu/.gitignore @@ -0,0 +1,32 @@ +tests/__pycache__/** + +# Local .terraform directories +.terraform/ + +# .tfstate files +*.tfstate +*.tfstate.* + +# Crash log files +crash.log +crash.*.log + +# Ignore override files as they are usually used to override resources locally and so +# are not checked in +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# Ignore transient lock info files created by terraform apply +.terraform.tfstate.lock.info + +# Include override files you do wish to add to version control using negated pattern +# !example_override.tf + +# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan +# example: *tfplan* + +# Ignore CLI configuration files +.terraformrc +terraform.rc diff --git a/tofu/example-config.tfvars b/tofu/example-config.tfvars new file mode 100644 index 0000000..f3e075a --- /dev/null +++ b/tofu/example-config.tfvars @@ -0,0 +1,62 @@ +# coral_uri = "http://credits.apps." +# auth_token = Get bearer token with `curl -X POST -H "Content-Type: application/json" -d \ +# "{ +# \"username\": \"admin\", +# \"password\": \"$TEST_PASSWORD\" +# }" \ http://credits.apps./api_auth_token/ + +resource_provider_name = "Test Provider" +resource_provider_email = "testprovider@example.com" +resource_provider_info_url = "https://www.google.com" + +accounts = [ + { + name = "TestAccount1" + email = "testaccount1@example.com" + openstack_project_id = "c2eced313b324cdb8e670e6e30bf387d" + }, + { + name = "TestAccount2" + email = "testaccount2@example.com" + openstack_project_id = "2fbf511968aa443e883a82283b0f0160" + } +] + +allocations = { + Q1 = { + start_date = "2025-09-01-12:00:00" + end_date = "2025-12-01-12:00:00" + projects = [ + { + account_email = "testaccount1@example.com" + resources = { + VCPU = 40000 + MEMORY_MB = 4423680 + DISK_GB = 108000 + } + }, + { + account_email = "testaccount2@example.com" + resources = { + VCPU = 20000 + MEMORY_MB = 2000000 + DISK_GB = 200000 + } + } + ] + } + Q2 = { + start_date = "2026-01-01-12:00:00" + end_date = "2026-04-01-12:00:00" + projects = [ + { + account_email = "testaccount1@example.com" + resources = { + VCPU = 80000 + MEMORY_MB = 8000000 + DISK_GB = 300000 + } + } + ] + } +} diff --git a/tofu/main.tf b/tofu/main.tf new file mode 100644 index 0000000..56f2986 --- /dev/null +++ b/tofu/main.tf @@ -0,0 +1,36 @@ +terraform { + required_providers { + restapi = { + source = "Mastercard/restapi" + version = "1.20.0" + } + } +} + +provider "restapi" { + alias = "coral" + uri = var.coral_uri + debug = false + write_returns_object = true + create_returns_object = true + id_attribute = "id" + headers = { + "Content-Type" = "application/json" + Authorization = "Bearer ${var.auth_token}" + } +} + +module "coral_tofu" { + source = "git::https://github.com/stackhpc/coral-credits-tofu.git?ref=main" #TODO: move to versioned release + + resource_provider_name = var.resource_provider_name + resource_provider_email = var.resource_provider_email + resource_provider_info_url = var.resource_provider_info_url + allocations = var.allocations + accounts = var.accounts + resource_classes = var.resource_classes + + providers = { + restapi.coral = restapi.coral + } +} \ No newline at end of file diff --git a/tofu/tests/tofu_test_data.py b/tofu/tests/tofu_test_data.py new file mode 100644 index 0000000..27be7f6 --- /dev/null +++ b/tofu/tests/tofu_test_data.py @@ -0,0 +1,97 @@ +from datetime import datetime +from dateutil.relativedelta import relativedelta +import uuid +import json +import copy + +def get_standard_test_vars(q1_start,q1_end,q2_start,q2_end,q1_0_resources): + return { + "resource_provider_name": "Test Provider", + "resource_provider_email": "testprovider@example.com", + "resource_provider_info_url": "https://www.google.com", + "accounts": json.dumps([ + { + "name": "TestAccount1", + "email": "testaccount1@example.com", + "openstack_project_id": "c2eced313b324cdb8e670e6e30bf387d" + }, + { + "name": "TestAccount2", + "email": "testaccount2@example.com", + "openstack_project_id": "2fbf511968aa443e883a82283b0f0160" + } + ]), + "allocations": json.dumps({ + "Q1": { + "start_date": str(q1_start), + "end_date": str(q1_end), + "projects": [ + { + "account_email": "testaccount1@example.com", + "resources": q1_0_resources + }, + { + "account_email": "testaccount2@example.com", + "resources": { + "VCPU": "20000", + "MEMORY_MB": "2000000", + "DISK_GB": "200000" + } + } + ] + }, + "Q2": { + "start_date": str(q2_start), + "end_date": str(q2_end), + "projects": [ + { + "account_email": "testaccount1@example.com", + "resources": { + "VCPU": "80000", + "MEMORY_MB": "8000000", + "DISK_GB": "300000" + } + } + ] + } + }) + } + +def get_empty_test_data_copy(data): + tmp = copy.deepcopy(data) + tmp["allocations"] = "{}" + tmp["accounts"] = "[]" + return tmp + +def get_no_q1_copy(data): + tmp = copy.deepcopy(data) + tmp["allocations"] = json.dumps({"Q2": json.loads(tmp["allocations"])["Q2"]}) + return tmp + +def get_lease_request_json(start,end): + start_time = start + end_time = end + return { + "context": { + "user_id": "caa8b54a-eb5e-4134-8ae2-a3946a428ec7", + "project_id": "c2eced313b324cdb8e670e6e30bf387d", + "auth_url": "http://api.example.com:5000/v3", + "region_name": "RegionOne", + }, + "lease": { + "id": str(uuid.uuid4()), + "name": "my_new_lease", + "start_date": start_time, + "end_date": end_time, + "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}, + }, + } diff --git a/tofu/tests/tofu_tests.py b/tofu/tests/tofu_tests.py new file mode 100644 index 0000000..c7b62d6 --- /dev/null +++ b/tofu/tests/tofu_tests.py @@ -0,0 +1,320 @@ +import pytest +from tofupy import Tofu +import requests +import os +from datetime import datetime +from dateutil.relativedelta import relativedelta +import uuid +import json +import tofu_test_data + +coral_uri = os.environ.get("TF_VAR_coral_uri") +headers = {"Authorization": "Bearer " + os.environ.get("TF_VAR_auth_token")} + + +initial_time = datetime.now() +def time_with_month_offset(offset): + return (datetime.now() + relativedelta(months=offset)).strftime("%Y-%m-%d-%H:%M:%S") + +q1_standard_resources = { + "VCPU": "40000", + "MEMORY_MB": "4423680", + "DISK_GB": "108000" +} + +q1_extra_resources = { + "VCPU": "41000", + "MEMORY_MB": "4424680", + "DISK_GB": "109000" +} + +q1_insufficient_resources = { + "VCPU": "10", + "MEMORY_MB": "10", + "DISK_GB": "10" +} + +q1_st = time_with_month_offset(-2) +q1_end = time_with_month_offset(1) +q2_st = time_with_month_offset(12) +q2_end = time_with_month_offset(15) + +standard_test_data = tofu_test_data.get_standard_test_vars( + q1_st, + q1_end, + q2_st, + q2_end, + q1_standard_resources + ) + +updated_test_data = tofu_test_data.get_standard_test_vars( + q1_st, + q1_end, + q2_st, + q2_end, + q1_extra_resources + ) + +empty_test_data = tofu_test_data.get_empty_test_data_copy(standard_test_data) +try_active_delete_test_data = tofu_test_data.get_no_q1_copy(standard_test_data) + +lease_request_json = tofu_test_data.get_lease_request_json( + time_with_month_offset(-1), + (initial_time + relativedelta(days=1)).strftime("%Y-%m-%d-%H:%M:%S") + ) + +@pytest.fixture(scope="session") +def terraform_rest_setup(): + working_dir = os.path.join(os.path.dirname(__file__), "..") + var_file = os.path.join(working_dir, "tests", "tofu_configs", "initial.tfvars") + delete_file = os.path.join(working_dir, "tests", "tofu_configs", "empty.tfvars") + + tf = Tofu(cwd=working_dir) + tf.init() + tf.apply(variables=standard_test_data) + + yield tf + + destroy = tf.apply(variables=empty_test_data) + assert len(destroy.errors) == 0 + + +@pytest.fixture(scope="session") +def add_consumer_request(terraform_rest_setup): + # Add consumer outside of tofu to simulate requests from Blazar + + consumer = requests.post( + coral_uri + "/consumer/create", + headers={ + "Authorization": "Bearer " + os.environ.get("TF_VAR_auth_token"), + "Content-Type": "application/json", + }, + json=lease_request_json, + ) + return dict(status=consumer.status_code, tf_workspace=terraform_rest_setup) + +@pytest.fixture(scope="session") +def update_allocation_resources(add_consumer_request): + workspace = add_consumer_request["tf_workspace"] + workspace.apply(variables=updated_test_data) + return dict(tf_workspace=add_consumer_request["tf_workspace"]) + +@pytest.fixture(scope="session") +def try_delete_active_allocation(update_allocation_resources): + print("Testing deleting active allocation, will see 403 errors") + try_delete = update_allocation_resources["tf_workspace"].apply( + variables = try_active_delete_test_data + ) + print("End of active allocation delete test") + return dict( + error_count=len(try_delete.errors), + tf_workspace=update_allocation_resources["tf_workspace"], + ) + + +@pytest.fixture(scope="session") +def try_destroy_with_active_consumers(try_delete_active_allocation): + + print("Testing destroy with active consumer, will see 403 errors") + try_delete = try_delete_active_allocation["tf_workspace"].apply( + variables=empty_test_data + ) + print("End of destroy with active consumers test") + yield len(try_delete.errors) + # Undo any destroys + reapply = try_delete_active_allocation["tf_workspace"].apply( + variables=standard_test_data + ) + assert len(reapply.errors) == 0 + + +@pytest.fixture(scope="session") +def delete_consumer(try_destroy_with_active_consumers): + requests.post( + coral_uri + "/consumer/on-end", + headers={ + "Authorization": "Bearer " + os.environ.get("TF_VAR_auth_token"), + "Content-Type": "application/json", + }, + json=lease_request_json, + ) + return + + +def api_get_request(resource): + return requests.get(coral_uri + "/" + resource, headers=headers).json() + + +def contains_only(actual, expected): + return len(actual) == len(expected) and set(expected).issubset(actual) + + +def test_resource_classes_created(terraform_rest_setup): + resp = api_get_request("resource_class") + created_resource_classes = [c["name"] for c in resp] + + assert contains_only(created_resource_classes, ["DISK_GB", "MEMORY_MB", "VCPU"]) + + +def test_resource_provider_created(terraform_rest_setup): + providers = api_get_request("resource_provider") + provider_names = [p["name"] for p in providers] + + assert contains_only(provider_names, ["Test Provider"]) + + +def test_accounts_created(terraform_rest_setup): + accounts = api_get_request("account") + account_names = [a["name"] for a in accounts] + + assert contains_only(account_names, ["TestAccount1", "TestAccount2"]) + + +def test_rpas_created(terraform_rest_setup): + rpas = api_get_request("resource_provider_account") + accounts = api_get_request("account") + rpa_account_urls = [a["account"] for a in rpas] + account_urls = [a["url"] for a in accounts] + assert contains_only(rpa_account_urls, account_urls) + + +def test_allocations_created(terraform_rest_setup): + allocations = api_get_request("allocation") + allocations_names = [a["name"] for a in allocations] + + assert contains_only(allocations_names, ["Q1-0", "Q1-1", "Q2-0"]) + + +def test_allocation_user_mappings(terraform_rest_setup): + allocations = api_get_request("allocation") + q1_allocations = [a for a in allocations if a["name"][:2] == "Q1"] + q2_allocations = [a for a in allocations if a["name"][:2] == "Q2"] + + accounts = api_get_request("account") + account_urls = {a["name"]: a["url"] for a in accounts} + + q1_allocated_accounts = [a["account"] for a in q1_allocations] + q2_allocated_accounts = [a["account"] for a in q2_allocations] + + assert contains_only( + q1_allocated_accounts, + [account_urls["TestAccount1"], account_urls["TestAccount2"]], + ) + assert contains_only(q2_allocated_accounts, [account_urls["TestAccount1"]]) + + +def test_allocation_date_mappings(terraform_rest_setup): + allocations = api_get_request("allocation") + q1_allocations = [a for a in allocations if a["name"][:2] == "Q1"] + q2_allocations = [a for a in allocations if a["name"][:2] == "Q2"] + + q1_allocation_starts = [a["start"] for a in q1_allocations] + q2_allocation_starts = [a["start"] for a in q2_allocations] + + assert len(set(q1_allocation_starts)) == 1 + assert len(q2_allocation_starts) == 1 + assert q1_allocation_starts[0] != q2_allocation_starts[0] + + +def to_resource_map(allocation_resources): + return { + a["resource_class"]["name"]: a["resource_hours"] for a in allocation_resources + } + + +def test_only_allocation_resources_returned(terraform_rest_setup): + allocation_id = api_get_request("allocation")[0]["id"] + assert len(api_get_request("allocation/" + str(allocation_id) + "/resources")) == 3 + + +@pytest.mark.parametrize( + "fixture_name, expected_resources", + [ + ( + "terraform_rest_setup", + { + "Q1-0": {"VCPU": 40000, "MEMORY_MB": 4423680, "DISK_GB": 108000}, + "Q1-1": {"VCPU": 20000, "MEMORY_MB": 2000000, "DISK_GB": 200000}, + "Q2-0": {"VCPU": 80000, "MEMORY_MB": 8000000, "DISK_GB": 300000}, + }, + ), + ( + "add_consumer_request", + { + # q1-0 = original - 31 days * 24 hours * lease resources + "Q1-0": {"VCPU": 37024, "MEMORY_MB": 3679680, "DISK_GB": 81960}, + "Q1-1": {"VCPU": 20000, "MEMORY_MB": 2000000, "DISK_GB": 200000}, + "Q2-0": {"VCPU": 80000, "MEMORY_MB": 8000000, "DISK_GB": 300000}, + }, + ), + ( + "update_allocation_resources", + { + # q1-0 after consumption + new resources + "Q1-0": {"VCPU": 38024, "MEMORY_MB": 3680680, "DISK_GB": 82960}, + "Q1-1": {"VCPU": 20000, "MEMORY_MB": 2000000, "DISK_GB": 200000}, + "Q2-0": {"VCPU": 80000, "MEMORY_MB": 8000000, "DISK_GB": 300000}, + }, + ), + ( + "delete_consumer", + { + # historical consumer consumption data should be preserved + "Q1-0": {"VCPU": 38024, "MEMORY_MB": 3680680, "DISK_GB": 82960}, + "Q1-1": {"VCPU": 20000, "MEMORY_MB": 2000000, "DISK_GB": 200000}, + "Q2-0": {"VCPU": 80000, "MEMORY_MB": 8000000, "DISK_GB": 300000}, + }, + ), + ], +) +def test_resource_allocations_have_correct_resources( + request, fixture_name, expected_resources +): + request.getfixturevalue(fixture_name) # needed to dynamically set fixtures + allocations = api_get_request("allocation") + allocation_resources = { + a["name"]: to_resource_map( + api_get_request("allocation/" + str(a["id"]) + "/resources") + ) + for a in allocations + } + assert allocation_resources["Q1-0"] == expected_resources["Q1-0"] + assert allocation_resources["Q1-1"] == expected_resources["Q1-1"] + assert allocation_resources["Q2-0"] == expected_resources["Q2-0"] + + +def test_consumer_added_or_exists(add_consumer_request): + assert add_consumer_request["status"] == 204 + + +def test_can_query_consumer(add_consumer_request): + assert len(api_get_request("consumer")) == 1 + + +def test_delete_allocation_with_consumer_forbidden(try_delete_active_allocation): + assert try_delete_active_allocation["error_count"] > 0 + + +def test_delete_active_allocation_resources_fails(try_delete_active_allocation): + allocations = api_get_request("allocation") + q1_allocations = [a for a in allocations if a["name"][:2] == "Q1"] + for alloc in q1_allocations: + tst = api_get_request("allocation/" + str(alloc["id"]) + "/resources") + assert len(tst) == 3 + + +def test_destroy_with_consumers_fails(try_destroy_with_active_consumers): + assert try_destroy_with_active_consumers > 0 + + +def test_all_resources_fail_destroy_for_active_consumers( + try_destroy_with_active_consumers, +): + assert len(api_get_request("resource_class")) == 3 + assert len(api_get_request("resource_provider")) == 1 + assert len(api_get_request("account")) == 2 + assert len(api_get_request("resource_provider_account")) == 2 + assert ( + len(api_get_request("allocation")) == 2 + ) # Q2 has no active consumers so destroyed + assert len(api_get_request("consumer")) == 1 diff --git a/tofu/variables.tf b/tofu/variables.tf new file mode 100644 index 0000000..325e54d --- /dev/null +++ b/tofu/variables.tf @@ -0,0 +1,32 @@ +variable "coral_uri" { + type = string +} + +variable "auth_token" { + type = string +} + +variable "resource_provider_name" { + type = string +} + +variable "resource_provider_email" { + type = string +} + +variable "resource_provider_info_url" { + type = string +} + +variable "allocations" { + type = map(any) +} + +variable "accounts" { + type = list(map(string)) +} + +variable "resource_classes" { + type = list(string) + default = ["VCPU", "MEMORY_MB", "DISK_GB"] +}