diff --git a/src/openstack_billing_db/billing.py b/src/openstack_billing_db/billing.py index 7c1b7c7..e3fed37 100644 --- a/src/openstack_billing_db/billing.py +++ b/src/openstack_billing_db/billing.py @@ -8,11 +8,18 @@ import os from openstack_billing_db import model +from openstack_billing_db import utils import boto3 logger = logging.getLogger(__name__) +# FIXME(knikolla): Temporarily hardcoding the days of outage here +# until we have formalized how to store them in the nerc-rates repo. +# Usage during these intervals is subtracted from the usage during +# the month. +OUTAGES_FOR_MONTH = {"2024-05": [("2024-05-22", "2024-05-29")]} + @dataclass() class Rates(object): @@ -81,7 +88,26 @@ def gpu_a2_su_cost(self) -> Decimal: return self.rates.gpu_a2 * self.gpu_a2_su_hours -def collect_invoice_data_from_openstack(database, billing_start, billing_end, rates): +def get_runtime_for_instance( + instance: model.Instance, + start: datetime, + end: datetime, + excluded_intervals: list[(str, str)], +): + runtime = instance.get_runtime_during(start, end) + for interval in excluded_intervals: + excluded_runtime = instance.get_runtime_during( + start_time=utils.parse_time_from_string(interval[0]), + end_time=utils.parse_time_from_string(interval[1]), + ) + runtime = runtime - excluded_runtime + + return runtime + + +def collect_invoice_data_from_openstack( + database, billing_start, billing_end, rates, invoice_month=None +): invoices = [] for project in database.projects: invoice = ProjectInvoice( @@ -94,8 +120,14 @@ def collect_invoice_data_from_openstack(database, billing_start, billing_end, ra rates=rates, ) + excluded_intervals = [] + if invoice_month: + excluded_intervals = OUTAGES_FOR_MONTH.get(invoice_month, []) + for i in project.instances: # type: model.Instance - runtime = i.get_runtime_during(billing_start, billing_end) + runtime = get_runtime_for_instance( + i, billing_start, billing_end, excluded_intervals + ) runtime_seconds = runtime.total_seconds_running if rates.include_stopped_runtime: runtime_seconds += runtime.total_seconds_stopped @@ -221,7 +253,9 @@ def generate_billing( ): database = model.Database(start, sql_dump_file) - invoices = collect_invoice_data_from_openstack(database, start, end, rates) + invoices = collect_invoice_data_from_openstack( + database, start, end, rates, invoice_month=invoice_month + ) if coldfront_data_file: merge_coldfront_data(invoices, coldfront_data_file) write(invoices, output, invoice_month) diff --git a/src/openstack_billing_db/main.py b/src/openstack_billing_db/main.py index c5f2aef..37f6e55 100644 --- a/src/openstack_billing_db/main.py +++ b/src/openstack_billing_db/main.py @@ -4,7 +4,7 @@ import argparse import logging -from openstack_billing_db import billing, fetch +from openstack_billing_db import billing, fetch, utils logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -12,7 +12,7 @@ def parse_time_argument(arg): if isinstance(arg, str): - return datetime.strptime(arg, "%Y-%m-%d") + return utils.parse_time_from_string(arg) return arg diff --git a/src/openstack_billing_db/model.py b/src/openstack_billing_db/model.py index 8b9cc21..b248391 100644 --- a/src/openstack_billing_db/model.py +++ b/src/openstack_billing_db/model.py @@ -69,6 +69,12 @@ class InstanceRuntime(object): total_seconds_running: int = 0 total_seconds_stopped: int = 0 + def __sub__(self, other): + return InstanceRuntime( + self.total_seconds_running - other.total_seconds_running, + self.total_seconds_stopped - other.total_seconds_stopped, + ) + @dataclass class Instance(object): @@ -100,20 +106,15 @@ def get_runtime_during(self, start_time, end_time): in_error_state = False delete_action_found = False - # If the instance as a deleted_at time, clamp it to within - # the invoicing period. - if self.deleted_at: - self.deleted_at = self._clamp_time(self.deleted_at, start_time, end_time) - for event in self.events: - event.time = self._clamp_time(event.time, start_time, end_time) + event_time = self._clamp_time(event.time, start_time, end_time) if event.message == "Error": in_error_state = True continue if event.name in ["create", "start"]: - last_start = event.time + last_start = event_time in_error_state = False # Count stopped time from last known stop. @@ -129,7 +130,7 @@ def get_runtime_during(self, start_time, end_time): delete_action_found = True if event.name in ["delete", "stop"]: - last_stop = event.time + last_stop = event_time # Count running time from last known start. if last_start: @@ -146,7 +147,7 @@ def get_runtime_during(self, start_time, end_time): if self.deleted_at and not delete_action_found: self.no_delete_action = True - end_time = self.deleted_at + end_time = self._clamp_time(self.deleted_at, start_time, end_time) # Handle the time since the last event. if last_start: diff --git a/src/openstack_billing_db/tests/unit/test_billing.py b/src/openstack_billing_db/tests/unit/test_billing.py new file mode 100644 index 0000000..d30c703 --- /dev/null +++ b/src/openstack_billing_db/tests/unit/test_billing.py @@ -0,0 +1,29 @@ +import uuid +from datetime import datetime, timedelta + +from openstack_billing_db import billing +from openstack_billing_db.model import Instance, InstanceEvent +from openstack_billing_db.tests.unit.utils import FLAVORS, MINUTE, HOUR, DAY, MONTH + + +def test_instance_simple_runtime(): + time = datetime(year=2000, month=1, day=1, hour=0, minute=0, second=0) + events = [ + InstanceEvent(time=time, name="create", message=""), + InstanceEvent(time=time + timedelta(days=15), name="delete", message=""), + ] + i = Instance( + uuid=uuid.uuid4().hex, name=uuid.uuid4().hex, flavor=FLAVORS[1], events=events + ) + + r = billing.get_runtime_for_instance( + i, + datetime(year=2000, month=1, day=1, hour=0, minute=0, second=0), + datetime(year=2000, month=2, day=1, hour=0, minute=0, second=0), + excluded_intervals=[ + ["2000-01-07", "2000-01-08"], + ["2000-01-01", "2000-01-02"], + ], + ) + assert r.total_seconds_running == (15 * DAY) - (DAY * 2) + assert r.total_seconds_stopped == 0 diff --git a/src/openstack_billing_db/tests/unit/test_instance.py b/src/openstack_billing_db/tests/unit/test_instance.py index c5753d0..a7fb0c4 100644 --- a/src/openstack_billing_db/tests/unit/test_instance.py +++ b/src/openstack_billing_db/tests/unit/test_instance.py @@ -1,14 +1,8 @@ import uuid from datetime import datetime, timedelta -from openstack_billing_db.model import Instance, InstanceEvent, Flavor - -FLAVORS = {1: Flavor(id=1, name="TestFlavor", vcpus=1, memory=4096, storage=10)} - -MINUTE = 60 -HOUR = 60 * MINUTE -DAY = HOUR * 24 -MONTH = 31 * DAY +from openstack_billing_db.model import Instance, InstanceEvent +from openstack_billing_db.tests.unit.utils import FLAVORS, MINUTE, HOUR, DAY, MONTH def test_instance_simple_runtime(): @@ -115,7 +109,7 @@ def test_instance_no_delete_action(): r = i.get_runtime_during( datetime(year=1999, month=11, day=1, hour=0, minute=0, second=0), - datetime(year=2000, month=12, day=1, hour=0, minute=0, second=0), + datetime(year=1999, month=12, day=1, hour=0, minute=0, second=0), ) assert r.total_seconds_running == 0 assert r.total_seconds_stopped == 0 diff --git a/src/openstack_billing_db/tests/unit/test_instance_runtime.py b/src/openstack_billing_db/tests/unit/test_instance_runtime.py new file mode 100644 index 0000000..1283f34 --- /dev/null +++ b/src/openstack_billing_db/tests/unit/test_instance_runtime.py @@ -0,0 +1,11 @@ +from openstack_billing_db.model import InstanceRuntime + + +def test_instance_runtime_subtract(): + a = InstanceRuntime(total_seconds_running=1000, total_seconds_stopped=1000) + b = InstanceRuntime(total_seconds_running=100, total_seconds_stopped=200) + c = a - b + assert c.total_seconds_running == 900 + assert c.total_seconds_running == a.total_seconds_running - b.total_seconds_running + assert c.total_seconds_stopped == 800 + assert c.total_seconds_stopped == a.total_seconds_stopped - b.total_seconds_stopped diff --git a/src/openstack_billing_db/tests/unit/utils.py b/src/openstack_billing_db/tests/unit/utils.py new file mode 100644 index 0000000..9359027 --- /dev/null +++ b/src/openstack_billing_db/tests/unit/utils.py @@ -0,0 +1,8 @@ +from openstack_billing_db import model + +FLAVORS = {1: model.Flavor(id=1, name="TestFlavor", vcpus=1, memory=4096, storage=10)} + +MINUTE = 60 +HOUR = 60 * MINUTE +DAY = HOUR * 24 +MONTH = 31 * DAY diff --git a/src/openstack_billing_db/utils.py b/src/openstack_billing_db/utils.py new file mode 100644 index 0000000..95447a3 --- /dev/null +++ b/src/openstack_billing_db/utils.py @@ -0,0 +1,5 @@ +from datetime import datetime + + +def parse_time_from_string(time_str: str) -> datetime: + return datetime.strptime(time_str, "%Y-%m-%d")