Skip to content

Commit 198ebe3

Browse files
authored
Merge pull request #261 from jimmysway/fix/256-pulling-outages
Integrate nerc-rates for dynamic outage information loading
2 parents ab472db + 17754fa commit 198ebe3

File tree

4 files changed

+131
-24
lines changed

4 files changed

+131
-24
lines changed

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
git+https://github.com/CCI-MOC/nerc-rates@33701ed#egg=nerc_rates
1+
git+https://github.com/CCI-MOC/nerc-rates@5569bba#egg=nerc_rates
22
boto3
33
kubernetes
44
openshift

src/coldfront_plugin_cloud/management/commands/calculate_storage_gb_hours.py

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@
2020

2121
_RATES = None
2222

23+
RESOURCE_NAME_TO_NERC_SERVICE = {
24+
"NERC": "stack",
25+
"NERC-OCP": "ocp-prod",
26+
"NERC-OCP-EDU": "academic",
27+
}
28+
2329

2430
def get_rates():
2531
# nerc-rates doesn't work with Python 3.9, which is what ColdFront is currently
@@ -142,13 +148,6 @@ def add_arguments(self, parser):
142148
action="store_true",
143149
help="Upload generated CSV invoice to S3 storage.",
144150
)
145-
parser.add_argument(
146-
"--excluded-time-ranges",
147-
type=str,
148-
default=None,
149-
nargs="+",
150-
help="List of time ranges excluded from billing, in ISO format.",
151-
)
152151

153152
@staticmethod
154153
def default_start_argument():
@@ -198,8 +197,24 @@ def upload_to_s3(s3_endpoint, s3_bucket, file_location, invoice_month):
198197
logger.info(f"Uploaded to {secondary_location}.")
199198

200199
def handle(self, *args, **options):
200+
def get_outages_for_service(resource_name: str):
201+
"""Get outages for a service from nerc-rates.
202+
203+
:param resource_name: Name of the resource to get outages for.
204+
:return: List of excluded intervals or None.
205+
"""
206+
service_name = RESOURCE_NAME_TO_NERC_SERVICE.get(resource_name)
207+
if service_name:
208+
return utils.load_outages_from_nerc_rates(
209+
options["start"], options["end"], service_name
210+
)
211+
return None
212+
201213
def process_invoice_row(allocation, attrs, su_name, rate):
202214
"""Calculate the value and write the bill using the writer."""
215+
resource_name = allocation.resources.first().name
216+
excluded_intervals_list = get_outages_for_service(resource_name)
217+
203218
time = 0
204219
for attribute in attrs:
205220
time += utils.calculate_quota_unit_hours(
@@ -234,13 +249,6 @@ def process_invoice_row(allocation, attrs, su_name, rate):
234249
logger.info(f"Processing invoices for {options['invoice_month']}.")
235250
logger.info(f"Interval {options['start'] - options['end']}.")
236251

237-
if options["excluded_time_ranges"]:
238-
excluded_intervals_list = utils.load_excluded_intervals(
239-
options["excluded_time_ranges"]
240-
)
241-
else:
242-
excluded_intervals_list = None
243-
244252
openstack_resources = Resource.objects.filter(
245253
resource_type=ResourceType.objects.get(name="OpenStack")
246254
)
@@ -285,15 +293,13 @@ def process_invoice_row(allocation, attrs, su_name, rate):
285293
csv_invoice_writer = csv.writer(
286294
f, delimiter=",", quotechar="|", quoting=csv.QUOTE_MINIMAL
287295
)
288-
# Write Headers
289296
csv_invoice_writer.writerow(InvoiceRow.get_headers())
290297

291298
for allocation in openstack_allocations:
292299
allocation_str = (
293300
f'{allocation.pk} of project "{allocation.project.title}"'
294301
)
295-
msg = f"Starting billing for allocation {allocation_str}."
296-
logger.debug(msg)
302+
logger.debug(f"Starting billing for allocation {allocation_str}.")
297303

298304
process_invoice_row(
299305
allocation,
@@ -306,8 +312,7 @@ def process_invoice_row(allocation, attrs, su_name, rate):
306312
allocation_str = (
307313
f'{allocation.pk} of project "{allocation.project.title}"'
308314
)
309-
msg = f"Starting billing for allocation {allocation_str}."
310-
logger.debug(msg)
315+
logger.debug(f"Starting billing for allocation {allocation_str}.")
311316

312317
process_invoice_row(
313318
allocation,

src/coldfront_plugin_cloud/tests/unit/test_calculate_quota_unit_hours.py

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import csv
12
import datetime
23
import pytz
34
import tempfile
5+
from decimal import Decimal
6+
from unittest.mock import Mock, patch
47

58
import freezegun
69

@@ -16,7 +19,11 @@
1619

1720

1821
class TestCalculateAllocationQuotaHours(base.TestBase):
19-
def test_new_allocation_quota(self):
22+
@patch("coldfront_plugin_cloud.utils.load_outages_from_nerc_rates")
23+
def test_new_allocation_quota(self, mock_load_outages):
24+
"""Test quota calculation with nerc-rates outages mocked."""
25+
mock_load_outages.return_value = []
26+
2027
self.resource = self.new_openshift_resource(
2128
name="",
2229
)
@@ -63,7 +70,7 @@ def test_new_allocation_quota(self):
6370
"2020-03",
6471
)
6572

66-
# Let's test a complete CLI call including excluded time, while we're at it. This is not for testing
73+
# Let's test a complete CLI call. This is not for testing
6774
# the validity but just the unerrored execution of the complete pipeline.
6875
# Tests that verify the correct output are further down in the test file.
6976
with tempfile.NamedTemporaryFile() as fp:
@@ -83,10 +90,12 @@ def test_new_allocation_quota(self):
8390
"0.00001",
8491
"--invoice-month",
8592
"2020-03",
86-
"--excluded-time-ranges",
87-
"2020-03-02 00:00:00,2020-03-03 05:00:00",
8893
)
8994

95+
# Verify that load_outages_from_nerc_rates is not called when resource name
96+
# doesn't match NERC service mapping
97+
mock_load_outages.assert_not_called()
98+
9099
def test_new_allocation_quota_expired(self):
91100
"""Test that expiration doesn't affect invoicing."""
92101
self.resource = self.new_openshift_resource(
@@ -597,3 +606,65 @@ def test_load_excluded_intervals_invalid(self):
597606
]
598607
with self.assertRaises(AssertionError):
599608
utils.load_excluded_intervals(invalid_interval)
609+
610+
@patch(
611+
"coldfront_plugin_cloud.management.commands.calculate_storage_gb_hours.RESOURCE_NAME_TO_NERC_SERVICE",
612+
{"TEST-RESOURCE": "test-service"},
613+
)
614+
@patch(
615+
"coldfront_plugin_cloud.management.commands.calculate_storage_gb_hours.get_rates"
616+
)
617+
def test_nerc_outages_integration(self, mock_rates_loader):
618+
"""Test nerc-rates integration: get_outages_during called correctly and outages reduce billing."""
619+
start = pytz.utc.localize(datetime.datetime(2020, 3, 1, 0, 0, 0))
620+
end = pytz.utc.localize(datetime.datetime(2020, 3, 31, 0, 0, 0))
621+
mock_outages = [
622+
(
623+
pytz.utc.localize(datetime.datetime(2020, 3, 10, 0, 0, 0)),
624+
pytz.utc.localize(datetime.datetime(2020, 3, 12, 0, 0, 0)),
625+
)
626+
]
627+
628+
mock_outages_data = Mock()
629+
mock_outages_data.get_outages_during.return_value = mock_outages
630+
mock_rates_loader.return_value.get_value_at.return_value = Decimal("0.001")
631+
632+
with patch.object(utils, "_OUTAGES_DATA", mock_outages_data):
633+
with freezegun.freeze_time("2020-03-01"):
634+
user = self.new_user()
635+
project = self.new_project(pi=user)
636+
resource = self.new_openstack_resource(name="TEST-RESOURCE")
637+
allocation = self.new_allocation(project, resource, 100)
638+
for attr, val in [
639+
(attributes.ALLOCATION_PROJECT_NAME, "test"),
640+
(attributes.ALLOCATION_PROJECT_ID, "123"),
641+
(attributes.QUOTA_VOLUMES_GB, 10),
642+
]:
643+
utils.set_attribute_on_allocation(allocation, attr, val)
644+
645+
with tempfile.NamedTemporaryFile(
646+
mode="w", delete=False, suffix=".csv"
647+
) as fp:
648+
output_file = fp.name
649+
650+
call_command(
651+
"calculate_storage_gb_hours",
652+
"--output",
653+
output_file,
654+
"--start",
655+
"2020-03-01",
656+
"--end",
657+
"2020-03-31",
658+
"--invoice-month",
659+
"2020-03",
660+
)
661+
662+
mock_outages_data.get_outages_during.assert_called_once_with(
663+
start.isoformat(), end.isoformat(), "test-service"
664+
)
665+
666+
with open(output_file, "r") as f:
667+
rows = list(csv.DictReader(f))
668+
669+
self.assertEqual(len(rows), 1)
670+
self.assertEqual(int(rows[0]["SU Hours (GBhr or SUhr)"]), 6720)

src/coldfront_plugin_cloud/utils.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import datetime
2+
import functools
23
import math
34
import pytz
45
import re
@@ -14,6 +15,9 @@
1415

1516
from coldfront_plugin_cloud import attributes
1617

18+
# Load outages data once per program execution
19+
_OUTAGES_DATA = None
20+
1721

1822
def env_safe_name(name):
1923
return re.sub(r"[^A-Za-z0-9]", "_", str(name)).upper()
@@ -191,6 +195,12 @@ def calculate_quota_unit_hours(
191195

192196

193197
def load_excluded_intervals(excluded_interval_arglist):
198+
"""Parse excluded time ranges from command line arguments.
199+
200+
:param excluded_interval_arglist: List of time range strings in format "start,end".
201+
:return: Sorted list of [start, end] datetime tuples.
202+
"""
203+
194204
def interval_sort_key(e):
195205
return e[0]
196206

@@ -223,6 +233,27 @@ def check_overlapping_intervals(excluded_intervals_list):
223233
return excluded_intervals_list
224234

225235

236+
@functools.cache
237+
def load_outages_from_nerc_rates(
238+
start: datetime.datetime, end: datetime.datetime, affected_service: str
239+
) -> list[tuple[datetime.datetime, datetime.datetime]]:
240+
"""Load outage intervals from nerc-rates for a given time period and service.
241+
242+
:param start: Start time for outage search.
243+
:param end: End time for outage search.
244+
:param affected_service: Name of the affected service (e.g., "stack", "ocp-prod").
245+
:return: List of [start, end] datetime tuples representing outages.
246+
"""
247+
global _OUTAGES_DATA
248+
if _OUTAGES_DATA is None:
249+
from nerc_rates import outages
250+
251+
_OUTAGES_DATA = outages.load_from_url()
252+
return _OUTAGES_DATA.get_outages_during(
253+
start.isoformat(), end.isoformat(), affected_service
254+
)
255+
256+
226257
def _clamp_time(time, min_time, max_time):
227258
if time < min_time:
228259
time = min_time

0 commit comments

Comments
 (0)