Skip to content

Commit 5bb6ffe

Browse files
committed
Add billing attributes to track allocation usage
Three new attributes have been added to track daily charges, previous charges, and an alert threshold for allocations. These attributes will help in monitoring and managing billing for cloud resources. More details described in #270
1 parent 772cd9f commit 5bb6ffe

File tree

4 files changed

+157
-0
lines changed

4 files changed

+157
-0
lines changed

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ boto3
33
kubernetes
44
openshift
55
coldfront >= 1.1.0
6+
pydantic
67
python-cinderclient # TODO: Set version for OpenStack Clients
78
python-keystoneclient
89
python-novaclient

src/coldfront_plugin_cloud/attributes.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ class CloudAllocationAttribute:
5454
ALLOCATION_PROJECT_ID = "Allocated Project ID"
5555
ALLOCATION_PROJECT_NAME = "Allocated Project Name"
5656
ALLOCATION_INSTITUTION_SPECIFIC_CODE = "Institution-Specific Code"
57+
ALLOCATION_CUMULATIVE_CHARGES = "Cumulative Daily Charges for Month"
58+
ALLOCATION_PREVIOUS_CHARGES = "Previous Charges"
59+
ALLOCATION_ALERT = "Monthly Allocation Cost Alert"
5760

5861
ALLOCATION_ATTRIBUTES = [
5962
CloudAllocationAttribute(
@@ -65,6 +68,19 @@ class CloudAllocationAttribute:
6568
CloudAllocationAttribute(
6669
name=ALLOCATION_INSTITUTION_SPECIFIC_CODE, type="Text", is_changeable=True
6770
),
71+
CloudAllocationAttribute(
72+
name=ALLOCATION_CUMULATIVE_CHARGES,
73+
type="Text",
74+
is_private=True,
75+
is_changeable=True,
76+
),
77+
CloudAllocationAttribute(
78+
name=ALLOCATION_PREVIOUS_CHARGES,
79+
type="Text",
80+
is_private=True,
81+
is_changeable=True,
82+
),
83+
CloudAllocationAttribute(name=ALLOCATION_ALERT, type="Int", is_changeable=True),
6884
]
6985

7086
###########################################################
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from decimal import Decimal
2+
from pydantic import ValidationError
3+
4+
from coldfront_plugin_cloud import usage_models
5+
from coldfront_plugin_cloud.tests import base
6+
7+
8+
class TestUsageModels(base.TestBase):
9+
def test_usage_info(self):
10+
# valid: values coerced to Decimal
11+
ui = usage_models.UsageInfo(
12+
root={"su-a": Decimal("1.5"), "su-b": 2, "su-c": "3.25"}
13+
)
14+
self.assertIsInstance(ui.root, dict)
15+
self.assertEqual(ui.root["su-a"], Decimal("1.5"))
16+
self.assertEqual(ui.root["su-b"], Decimal("2"))
17+
self.assertEqual(ui.root["su-c"], Decimal("3.25"))
18+
19+
# invalid: non-numeric string should raise ValidationError
20+
with self.assertRaises(ValidationError):
21+
usage_models.UsageInfo(root={"su-x": "not-a-number"})
22+
23+
def test_daily_charges_dict(self):
24+
# Valid CumulativeChargesDict with YYYY-MM-DD keys
25+
data = {
26+
"2025-11-29": {"su1": Decimal("1.0")},
27+
"2025-11-30": {"su1": Decimal("3.5"), "su2": Decimal("2.0")},
28+
}
29+
daily = usage_models.CumulativeChargesDict(root=data)
30+
# total_charges sums across all dates and SUs
31+
self.assertEqual(daily.total_charges, Decimal("5.5"))
32+
33+
# Empty dict -> totals should be zero/empty
34+
empty = usage_models.CumulativeChargesDict(root={})
35+
self.assertEqual(empty.total_charges, Decimal("0.0"))
36+
37+
# Invalid date key format should raise ValidationError
38+
with self.assertRaises(ValidationError):
39+
usage_models.CumulativeChargesDict(root={"2025-13-01": {"su": 1.0}})
40+
41+
with self.assertRaises(ValidationError):
42+
usage_models.CumulativeChargesDict(root={"2025-01": {"su": 1.0}})
43+
44+
# Different months should raise ValidationError
45+
with self.assertRaises(ValidationError):
46+
usage_models.CumulativeChargesDict(
47+
root={"2025-12-01": {"su": 1.0}, "2026-12-01": {"su": 1.0}}
48+
)
49+
50+
def test_previous_charges_dict(self):
51+
# Monthly (PreviousChargesDict) requires YYYY-MM keys
52+
prev_data = {
53+
"2025-11": {"suA": Decimal("5.0")},
54+
"2025-12": {"suA": Decimal("2.5"), "suB": Decimal("1.0")},
55+
}
56+
prev = usage_models.PreviousChargesDict(root=prev_data)
57+
self.assertEqual(
58+
prev.total_charges_by_su,
59+
{"suA": Decimal("7.5"), "suB": Decimal("1.0")},
60+
)
61+
self.assertEqual(prev.total_charges, Decimal("8.5"))
62+
63+
# Invalid month format should raise ValidationError
64+
with self.assertRaises(ValidationError):
65+
usage_models.PreviousChargesDict(root={"2025-11-01": {"su": 1.0}})
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import datetime
2+
import functools
3+
from decimal import Decimal
4+
from typing import Annotated, TypeVar
5+
6+
import pydantic
7+
8+
9+
def validate_date_str(v: str) -> str:
10+
datetime.datetime.strptime(v, "%Y-%m-%d")
11+
return v
12+
13+
14+
def validate_month_str(v: str) -> str:
15+
datetime.datetime.strptime(v, "%Y-%m")
16+
return v
17+
18+
19+
DateField = Annotated[str, pydantic.AfterValidator(validate_date_str)]
20+
MonthField = Annotated[str, pydantic.AfterValidator(validate_month_str)]
21+
22+
23+
class UsageInfo(pydantic.RootModel[dict[str, Decimal]]):
24+
pass
25+
26+
27+
T = TypeVar("T", bound=str)
28+
29+
30+
class ChargesDict(pydantic.RootModel[dict[T, UsageInfo]]):
31+
@functools.cached_property
32+
def most_recent_date(self) -> DateField:
33+
"""Leverage lexical ordering of YYYY-MM-DD and YYYY-MM strings."""
34+
return max(self.root.keys()) if self.root else ""
35+
36+
37+
class CumulativeChargesDict(ChargesDict[DateField]):
38+
@pydantic.model_validator(mode="after")
39+
def check_month(self):
40+
# Ensure all keys are in the same month
41+
if self.root:
42+
months = set()
43+
for date_str in self.root.keys():
44+
months.add(
45+
datetime.datetime.strptime(date_str, "%Y-%m-%d").strftime("%Y-%m")
46+
)
47+
48+
if len(months) != 1:
49+
raise ValueError("All dates must be within the same month")
50+
return self
51+
52+
@functools.cached_property
53+
def total_charges(self) -> Decimal:
54+
total = Decimal("0.00")
55+
if most_recent_charges := self.root.get(self.most_recent_date):
56+
for su_charge in most_recent_charges.root.values():
57+
total += su_charge
58+
return total
59+
60+
61+
class PreviousChargesDict(ChargesDict[MonthField]):
62+
@functools.cached_property
63+
def total_charges_by_su(self) -> dict[str, Decimal]:
64+
total = {}
65+
for usage_info in self.root.values():
66+
for su_name, charge in usage_info.root.items():
67+
total[su_name] = total.get(su_name, Decimal("0.00")) + charge
68+
return total
69+
70+
@functools.cached_property
71+
def total_charges(self) -> Decimal:
72+
total = Decimal("0.00")
73+
for su_charge in self.total_charges_by_su.values():
74+
total += su_charge
75+
return total

0 commit comments

Comments
 (0)