Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .tiltignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
tofu/
6 changes: 6 additions & 0 deletions coral_credits/api/db_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
17 changes: 10 additions & 7 deletions coral_credits/api/db_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand Down
18 changes: 3 additions & 15 deletions coral_credits/api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 "
Expand Down
13 changes: 10 additions & 3 deletions coral_credits/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand All @@ -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):
Expand Down
101 changes: 95 additions & 6 deletions coral_credits/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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):

Expand All @@ -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()
Expand All @@ -105,23 +160,38 @@ 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}
)

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"] = (
Expand All @@ -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}")
Expand Down
29 changes: 27 additions & 2 deletions coral_credits/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
32 changes: 32 additions & 0 deletions tofu/.gitignore
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading