Skip to content

Commit d679a83

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 d679a83

File tree

4 files changed

+152
-0
lines changed

4 files changed

+152
-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: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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 = {date_str[:7] for date_str in self.root.keys()}
43+
if len(months) != 1:
44+
raise ValueError("All dates must be within the same month")
45+
return self
46+
47+
@functools.cached_property
48+
def total_charges(self) -> Decimal:
49+
total = Decimal("0.00")
50+
if most_recent_charges := self.root.get(self.most_recent_date):
51+
for su_charge in most_recent_charges.root.values():
52+
total += su_charge
53+
return total
54+
55+
56+
class PreviousChargesDict(ChargesDict[MonthField]):
57+
@functools.cached_property
58+
def total_charges_by_su(self) -> dict[str, Decimal]:
59+
total = {}
60+
for usage_info in self.root.values():
61+
for su_name, charge in usage_info.root.items():
62+
total[su_name] = total.get(su_name, Decimal("0.00")) + charge
63+
return total
64+
65+
@functools.cached_property
66+
def total_charges(self) -> Decimal:
67+
total = Decimal("0.00")
68+
for su_charge in self.total_charges_by_su.values():
69+
total += su_charge
70+
return total

0 commit comments

Comments
 (0)