From af8b460dce3910343dc14bea1a1dafc84940c3e2 Mon Sep 17 00:00:00 2001 From: Naved Ansari Date: Mon, 24 Nov 2025 14:15:19 -0500 Subject: [PATCH 1/6] Add new columns for daily report --- openshift_metrics/invoice.py | 5 ++++- openshift_metrics/merge.py | 25 +++++++++++++++++++------ openshift_metrics/utils.py | 13 +++++++++++-- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/openshift_metrics/invoice.py b/openshift_metrics/invoice.py index 522435d..bbaf850 100644 --- a/openshift_metrics/invoice.py +++ b/openshift_metrics/invoice.py @@ -240,7 +240,7 @@ def get_rate(self, su_type) -> Decimal: return self.rates.gpu_h100 return Decimal(0) - def generate_invoice_rows(self, report_month) -> List[str]: + def generate_invoice_rows(self, report_month, report_start_time, report_end_time, generated_at) -> List[str]: rows = [] for su_type, hours in self.su_hours.items(): if hours > 0: @@ -249,6 +249,8 @@ def generate_invoice_rows(self, report_month) -> List[str]: cost = (rate * hours).quantize(Decimal(".01"), rounding=ROUND_HALF_UP) row = [ report_month, + report_start_time, + report_end_time, self.project, self.project_id, self.pi, @@ -261,6 +263,7 @@ def generate_invoice_rows(self, report_month) -> List[str]: su_type, rate, cost, + generated_at, ] rows.append(row) return rows diff --git a/openshift_metrics/merge.py b/openshift_metrics/merge.py index 0c29364..a0c599e 100644 --- a/openshift_metrics/merge.py +++ b/openshift_metrics/merge.py @@ -5,7 +5,7 @@ import sys import logging import argparse -from datetime import datetime, UTC +from datetime import datetime, UTC, timedelta import json from typing import Tuple from decimal import Decimal @@ -159,9 +159,7 @@ def main(): if cluster_name is None: cluster_name = "Unknown Cluster" - logger.info( - f"Generating report from {report_start_date} to {report_end_date} for {cluster_name}" - ) + logger.info(f"Total metric files read: {len(files)}") report_month = datetime.strftime( datetime.strptime(report_start_date, "%Y-%m-%d"), "%Y-%m" @@ -212,8 +210,12 @@ def main(): else: pod_report_file = f"Pod NERC OpenShift {report_month}.csv" - report_start_date = datetime.strptime(report_start_date, "%Y-%m-%d") - report_end_date = datetime.strptime(report_end_date, "%Y-%m-%d") + report_start_date = datetime.strptime(report_start_date, "%Y-%m-%d").replace(tzinfo=UTC) + report_end_date = datetime.strptime(report_end_date, "%Y-%m-%d").replace(tzinfo=UTC) + timedelta(days=1) + + logger.info( + f"Generating report from {report_start_date} to {report_end_date} for {cluster_name}" + ) if report_start_date.month != report_end_date.month: logger.warning("The report spans multiple months") @@ -224,6 +226,11 @@ def main(): ) su_definitions = get_su_definitions(report_month) + + generated_at = datetime.now(UTC).strftime("%Y-%m-%d%H:%M:%SZ") + report_start_time = report_start_date.strftime("%Y-%m-%d%H:%M:%SZ") + report_end_time = report_end_date.strftime("%Y-%m-%d%H:%M:%SZ") + utils.write_metrics_by_namespace( condensed_metrics_dict=condensed_metrics_dict, file_name=invoice_file, @@ -231,6 +238,9 @@ def main(): rates=invoice_rates, su_definitions=su_definitions, cluster_name=cluster_name, + report_start_time=report_start_time, + report_end_time=report_end_time, + generated_at=generated_at, ignore_hours=ignore_hours, ) utils.write_metrics_by_classes( @@ -241,6 +251,9 @@ def main(): su_definitions=su_definitions, cluster_name=cluster_name, namespaces_with_classes=["rhods-notebooks"], + report_start_time=report_start_time, + report_end_time=report_end_time, + generated_at=generated_at, ignore_hours=ignore_hours, ) utils.write_metrics_by_pod( diff --git a/openshift_metrics/utils.py b/openshift_metrics/utils.py index 6937ae0..4d04c95 100755 --- a/openshift_metrics/utils.py +++ b/openshift_metrics/utils.py @@ -64,6 +64,9 @@ def write_metrics_by_namespace( rates, su_definitions, cluster_name, + report_start_time, + report_end_time, + generated_at, ignore_hours=None, ): """ @@ -73,6 +76,8 @@ def write_metrics_by_namespace( rows = [] headers = [ "Invoice Month", + "Report Start Time", + "Report End Time", "Project - Allocation", "Project - Allocation ID", "Manager (PI)", @@ -85,6 +90,7 @@ def write_metrics_by_namespace( "SU Type", "Rate", "Cost", + "Generated At", ] rows.append(headers) @@ -128,7 +134,7 @@ def write_metrics_by_namespace( project_invoice.add_pod(pod_obj) for project_invoice in invoices.values(): - rows.extend(project_invoice.generate_invoice_rows(report_month)) + rows.extend(project_invoice.generate_invoice_rows(report_month, report_start_time, report_end_time, generated_at)) csv_writer(rows, file_name) @@ -190,6 +196,9 @@ def write_metrics_by_classes( namespaces_with_classes, su_definitions, cluster_name, + report_start_time, + report_end_time, + generated_at, ignore_hours=None, ): """ @@ -265,6 +274,6 @@ def write_metrics_by_classes( project_invoice.add_pod(pod_obj) for project_invoice in invoices.values(): - rows.extend(project_invoice.generate_invoice_rows(report_month)) + rows.extend(project_invoice.generate_invoice_rows(report_month, report_start_time, report_end_time, generated_at)) csv_writer(rows, file_name) From f76b2d2b38228831e5f1e54a3854a00732c3aef8 Mon Sep 17 00:00:00 2001 From: Naved Ansari Date: Mon, 24 Nov 2025 17:16:32 -0500 Subject: [PATCH 2/6] Restructure some things for a project invoice * Remove attributes that we get from coldfront from the invoice. Still keep the CSV output consistent though. * Hold report metadata in a dataclass and pass that around to the helper functions to keep things better contained. --- openshift_metrics/invoice.py | 38 +++++++++++++++++++----------------- openshift_metrics/merge.py | 30 ++++++++++++++-------------- openshift_metrics/utils.py | 30 ++++------------------------ 3 files changed, 39 insertions(+), 59 deletions(-) diff --git a/openshift_metrics/invoice.py b/openshift_metrics/invoice.py index bbaf850..aa69823 100644 --- a/openshift_metrics/invoice.py +++ b/openshift_metrics/invoice.py @@ -192,19 +192,21 @@ class Rates: gpu_h100: Decimal +@dataclass() +class ReportMetadata: + report_month: str + cluster_name: str + report_start_time: datetime.datetime + report_end_time: datetime.datetime + generated_at: datetime.datetime + + @dataclass class ProjectInvoce: """Represents the invoicing data for a project.""" - invoice_month: str project: str project_id: str - pi: str - cluster_name: str - invoice_email: str - invoice_address: str - intitution: str - institution_specific_code: str rates: Rates su_definitions: dict ignore_hours: Optional[List[Tuple[datetime.datetime, datetime.datetime]]] = None @@ -240,7 +242,7 @@ def get_rate(self, su_type) -> Decimal: return self.rates.gpu_h100 return Decimal(0) - def generate_invoice_rows(self, report_month, report_start_time, report_end_time, generated_at) -> List[str]: + def generate_invoice_rows(self, metadata: ReportMetadata) -> List[str]: rows = [] for su_type, hours in self.su_hours.items(): if hours > 0: @@ -248,22 +250,22 @@ def generate_invoice_rows(self, report_month, report_start_time, report_end_time rate = self.get_rate(su_type) cost = (rate * hours).quantize(Decimal(".01"), rounding=ROUND_HALF_UP) row = [ - report_month, - report_start_time, - report_end_time, + metadata.report_month, + metadata.report_start_time.strftime("%Y-%m-%d%H:%M:%SZ"), + metadata.report_end_time.strftime("%Y-%m-%d%H:%M:%SZ"), self.project, self.project_id, - self.pi, - self.cluster_name, - self.invoice_email, - self.invoice_address, - self.intitution, - self.institution_specific_code, + "", # pi + metadata.cluster_name, + "", # invoice_email + "", # invoice_address + "", # institution + "", # institution_specific_code hours, su_type, rate, cost, - generated_at, + metadata.generated_at.strftime("%Y-%m-%d%H:%M:%SZ"), ] rows.append(row) return rows diff --git a/openshift_metrics/merge.py b/openshift_metrics/merge.py index a0c599e..2182fd3 100644 --- a/openshift_metrics/merge.py +++ b/openshift_metrics/merge.py @@ -210,8 +210,12 @@ def main(): else: pod_report_file = f"Pod NERC OpenShift {report_month}.csv" - report_start_date = datetime.strptime(report_start_date, "%Y-%m-%d").replace(tzinfo=UTC) - report_end_date = datetime.strptime(report_end_date, "%Y-%m-%d").replace(tzinfo=UTC) + timedelta(days=1) + report_start_date = datetime.strptime(report_start_date, "%Y-%m-%d").replace( + tzinfo=UTC + ) + report_end_date = datetime.strptime(report_end_date, "%Y-%m-%d").replace( + tzinfo=UTC + ) + timedelta(days=1) logger.info( f"Generating report from {report_start_date} to {report_end_date} for {cluster_name}" @@ -227,33 +231,29 @@ def main(): su_definitions = get_su_definitions(report_month) - generated_at = datetime.now(UTC).strftime("%Y-%m-%d%H:%M:%SZ") - report_start_time = report_start_date.strftime("%Y-%m-%d%H:%M:%SZ") - report_end_time = report_end_date.strftime("%Y-%m-%d%H:%M:%SZ") + report_metadata = invoice.ReportMetadata( + report_month=report_month, + cluster_name=cluster_name, + report_start_time=report_start_date, + report_end_time=report_end_date, + generated_at=datetime.now(UTC), + ) utils.write_metrics_by_namespace( condensed_metrics_dict=condensed_metrics_dict, file_name=invoice_file, - report_month=report_month, + report_metadata=report_metadata, rates=invoice_rates, su_definitions=su_definitions, - cluster_name=cluster_name, - report_start_time=report_start_time, - report_end_time=report_end_time, - generated_at=generated_at, ignore_hours=ignore_hours, ) utils.write_metrics_by_classes( condensed_metrics_dict=condensed_metrics_dict, file_name=class_invoice_file, - report_month=report_month, + report_metadata=report_metadata, rates=invoice_rates, su_definitions=su_definitions, - cluster_name=cluster_name, namespaces_with_classes=["rhods-notebooks"], - report_start_time=report_start_time, - report_end_time=report_end_time, - generated_at=generated_at, ignore_hours=ignore_hours, ) utils.write_metrics_by_pod( diff --git a/openshift_metrics/utils.py b/openshift_metrics/utils.py index 4d04c95..3243247 100755 --- a/openshift_metrics/utils.py +++ b/openshift_metrics/utils.py @@ -60,13 +60,9 @@ def csv_writer(rows, file_name): def write_metrics_by_namespace( condensed_metrics_dict, file_name, - report_month, + report_metadata: invoice.ReportMetadata, rates, su_definitions, - cluster_name, - report_start_time, - report_end_time, - generated_at, ignore_hours=None, ): """ @@ -98,15 +94,8 @@ def write_metrics_by_namespace( for namespace, pods in condensed_metrics_dict.items(): if namespace not in invoices: project_invoice = invoice.ProjectInvoce( - invoice_month=report_month, project=namespace, project_id=namespace, - pi="", - cluster_name=cluster_name, - invoice_email="", - invoice_address="", - intitution="", - institution_specific_code="", rates=rates, su_definitions=su_definitions, ignore_hours=ignore_hours, @@ -134,7 +123,7 @@ def write_metrics_by_namespace( project_invoice.add_pod(pod_obj) for project_invoice in invoices.values(): - rows.extend(project_invoice.generate_invoice_rows(report_month, report_start_time, report_end_time, generated_at)) + rows.extend(project_invoice.generate_invoice_rows(report_metadata)) csv_writer(rows, file_name) @@ -191,14 +180,10 @@ def write_metrics_by_pod( def write_metrics_by_classes( condensed_metrics_dict, file_name, - report_month, + report_metadata: invoice.ReportMetadata, rates, namespaces_with_classes, su_definitions, - cluster_name, - report_start_time, - report_end_time, - generated_at, ignore_hours=None, ): """ @@ -240,15 +225,8 @@ def write_metrics_by_classes( if project_name not in invoices: project_invoice = invoice.ProjectInvoce( - invoice_month=report_month, project=project_name, project_id=project_name, - pi="", - cluster_name=cluster_name, - invoice_email="", - invoice_address="", - intitution="", - institution_specific_code="", su_definitions=su_definitions, rates=rates, ignore_hours=ignore_hours, @@ -274,6 +252,6 @@ def write_metrics_by_classes( project_invoice.add_pod(pod_obj) for project_invoice in invoices.values(): - rows.extend(project_invoice.generate_invoice_rows(report_month, report_start_time, report_end_time, generated_at)) + rows.extend(project_invoice.generate_invoice_rows(report_metadata)) csv_writer(rows, file_name) From 271f32c27d5dc7657c99c7b36ec69e6dfd58819e Mon Sep 17 00:00:00 2001 From: Naved Ansari Date: Tue, 25 Nov 2025 14:16:41 -0500 Subject: [PATCH 3/6] Fix tests Also update the headers for the report by classes, not that we specifically need it for that report, but it's just good to keep it consistent. --- openshift_metrics/tests/test_utils.py | 79 +++++++++++++++++---------- openshift_metrics/utils.py | 3 + 2 files changed, 53 insertions(+), 29 deletions(-) diff --git a/openshift_metrics/tests/test_utils.py b/openshift_metrics/tests/test_utils.py index 0cea1c6..d76adf0 100644 --- a/openshift_metrics/tests/test_utils.py +++ b/openshift_metrics/tests/test_utils.py @@ -109,6 +109,15 @@ def test_write_metrics_log(self): class TestWriteMetricsByNamespace(TestCase): + def setUp(self) -> None: + self.report_metadata = invoice.ReportMetadata( + report_month="2023-01", + cluster_name="test-cluster", + report_start_time=datetime(2023, 1, 1, tzinfo=UTC), + report_end_time=datetime(2023, 1, 3, tzinfo=UTC), + generated_at=datetime(2023, 1, 5, tzinfo=UTC), + ) + def test_write_metrics_log(self): test_metrics_dict = { "namespace1": { @@ -180,21 +189,20 @@ def test_write_metrics_log(self): } expected_output = ( - "Invoice Month,Project - Allocation,Project - Allocation ID,Manager (PI),Cluster Name,Invoice Email,Invoice Address,Institution,Institution - Specific Code,SU Hours (GBhr or SUhr),SU Type,Rate,Cost\n" - "2023-01,namespace1,namespace1,,test-cluster,,,,,1128,OpenShift CPU,0.013,14.66\n" - "2023-01,namespace2,namespace2,,test-cluster,,,,,96,OpenShift CPU,0.013,1.25\n" - "2023-01,namespace2,namespace2,,test-cluster,,,,,48,OpenShift GPUA100,1.803,86.54\n" - "2023-01,namespace2,namespace2,,test-cluster,,,,,48,OpenShift GPUA100SXM4,2.078,99.74\n" + "Invoice Month,Report Start Time,Report End Time,Project - Allocation,Project - Allocation ID,Manager (PI),Cluster Name,Invoice Email,Invoice Address,Institution,Institution - Specific Code,SU Hours (GBhr or SUhr),SU Type,Rate,Cost,Generated At\n" + "2023-01,2023-01-0100:00:00Z,2023-01-0300:00:00Z,namespace1,namespace1,,test-cluster,,,,,1128,OpenShift CPU,0.013,14.66,2023-01-0500:00:00Z\n" + "2023-01,2023-01-0100:00:00Z,2023-01-0300:00:00Z,namespace2,namespace2,,test-cluster,,,,,96,OpenShift CPU,0.013,1.25,2023-01-0500:00:00Z\n" + "2023-01,2023-01-0100:00:00Z,2023-01-0300:00:00Z,namespace2,namespace2,,test-cluster,,,,,48,OpenShift GPUA100,1.803,86.54,2023-01-0500:00:00Z\n" + "2023-01,2023-01-0100:00:00Z,2023-01-0300:00:00Z,namespace2,namespace2,,test-cluster,,,,,48,OpenShift GPUA100SXM4,2.078,99.74,2023-01-0500:00:00Z\n" ) with tempfile.NamedTemporaryFile(mode="w+") as tmp: utils.write_metrics_by_namespace( condensed_metrics_dict=test_metrics_dict, file_name=tmp.name, - report_month="2023-01", + report_metadata=self.report_metadata, rates=RATES, su_definitions=SU_DEFINITIONS, - cluster_name="test-cluster", ) self.assertEqual(tmp.read(), expected_output) @@ -229,24 +237,32 @@ def test_write_metrics_for_vms(self): } expected_output = ( - "Invoice Month,Project - Allocation,Project - Allocation ID,Manager (PI),Cluster Name,Invoice Email,Invoice Address,Institution,Institution - Specific Code,SU Hours (GBhr or SUhr),SU Type,Rate,Cost\n" - "2023-01,namespace1,namespace1,,test-cluster,,,,,24,OpenShift GPUA100SXM4,2.078,49.87\n" - "2023-01,namespace1,namespace1,,test-cluster,,,,,24,OpenShift GPUH100,6.04,144.96\n" + "Invoice Month,Report Start Time,Report End Time,Project - Allocation,Project - Allocation ID,Manager (PI),Cluster Name,Invoice Email,Invoice Address,Institution,Institution - Specific Code,SU Hours (GBhr or SUhr),SU Type,Rate,Cost,Generated At\n" + "2023-01,2023-01-0100:00:00Z,2023-01-0300:00:00Z,namespace1,namespace1,,test-cluster,,,,,24,OpenShift GPUA100SXM4,2.078,49.87,2023-01-0500:00:00Z\n" + "2023-01,2023-01-0100:00:00Z,2023-01-0300:00:00Z,namespace1,namespace1,,test-cluster,,,,,24,OpenShift GPUH100,6.04,144.96,2023-01-0500:00:00Z\n" ) with tempfile.NamedTemporaryFile(mode="w+") as tmp: utils.write_metrics_by_namespace( condensed_metrics_dict=test_metrics_dict, file_name=tmp.name, - report_month="2023-01", + report_metadata=self.report_metadata, rates=RATES, su_definitions=SU_DEFINITIONS, - cluster_name="test-cluster", ) self.assertEqual(tmp.read(), expected_output) class TestWriteMetricsByClasses(TestCase): + def setUp(self) -> None: + self.report_metadata = invoice.ReportMetadata( + report_month="2023-01", + cluster_name="test-cluster", + report_start_time=datetime(2023, 1, 1, tzinfo=UTC), + report_end_time=datetime(2023, 1, 3, tzinfo=UTC), + generated_at=datetime(2023, 1, 5, tzinfo=UTC), + ) + def test_write_metrics_log(self): test_metrics_dict = { "namespace1": { # namespace is ignored entirely from the report @@ -321,22 +337,21 @@ def test_write_metrics_log(self): } expected_output = ( - "Invoice Month,Project - Allocation,Project - Allocation ID,Manager (PI),Cluster Name,Invoice Email,Invoice Address,Institution,Institution - Specific Code,SU Hours (GBhr or SUhr),SU Type,Rate,Cost\n" - "2023-01,namespace2:noclass,namespace2:noclass,,test-cluster,,,,,96,OpenShift CPU,0.013,1.25\n" - "2023-01,namespace2:math-201,namespace2:math-201,,test-cluster,,,,,96,OpenShift CPU,0.013,1.25\n" - "2023-01,namespace2:math-201,namespace2:math-201,,test-cluster,,,,,24,OpenShift GPUA100,1.803,43.27\n" - "2023-01,namespace2:cs-101,namespace2:cs-101,,test-cluster,,,,,48,OpenShift GPUA100SXM4,2.078,99.74\n" + "Invoice Month,Report Start Time,Report End Time,Project - Allocation,Project - Allocation ID,Manager (PI),Cluster Name,Invoice Email,Invoice Address,Institution,Institution - Specific Code,SU Hours (GBhr or SUhr),SU Type,Rate,Cost,Generated At\n" + "2023-01,2023-01-0100:00:00Z,2023-01-0300:00:00Z,namespace2:noclass,namespace2:noclass,,test-cluster,,,,,96,OpenShift CPU,0.013,1.25,2023-01-0500:00:00Z\n" + "2023-01,2023-01-0100:00:00Z,2023-01-0300:00:00Z,namespace2:math-201,namespace2:math-201,,test-cluster,,,,,96,OpenShift CPU,0.013,1.25,2023-01-0500:00:00Z\n" + "2023-01,2023-01-0100:00:00Z,2023-01-0300:00:00Z,namespace2:math-201,namespace2:math-201,,test-cluster,,,,,24,OpenShift GPUA100,1.803,43.27,2023-01-0500:00:00Z\n" + "2023-01,2023-01-0100:00:00Z,2023-01-0300:00:00Z,namespace2:cs-101,namespace2:cs-101,,test-cluster,,,,,48,OpenShift GPUA100SXM4,2.078,99.74,2023-01-0500:00:00Z\n" ) with tempfile.NamedTemporaryFile(mode="w+") as tmp: utils.write_metrics_by_classes( condensed_metrics_dict=test_metrics_dict, file_name=tmp.name, - report_month="2023-01", + report_metadata=self.report_metadata, rates=RATES, su_definitions=SU_DEFINITIONS, namespaces_with_classes=["namespace2"], - cluster_name="test-cluster", ) self.assertEqual(tmp.read(), expected_output) @@ -370,18 +385,17 @@ def test_write_metrics_by_namespace_decimal(self): self.assertEqual(cost, 0.45) expected_output = ( - "Invoice Month,Project - Allocation,Project - Allocation ID,Manager (PI),Cluster Name,Invoice Email,Invoice Address,Institution,Institution - Specific Code,SU Hours (GBhr or SUhr),SU Type,Rate,Cost\n" - "2023-01,namespace1,namespace1,,test-cluster,,,,,35,OpenShift CPU,0.013,0.46\n" + "Invoice Month,Report Start Time,Report End Time,Project - Allocation,Project - Allocation ID,Manager (PI),Cluster Name,Invoice Email,Invoice Address,Institution,Institution - Specific Code,SU Hours (GBhr or SUhr),SU Type,Rate,Cost,Generated At\n" + "2023-01,2023-01-0100:00:00Z,2023-01-0300:00:00Z,namespace1,namespace1,,test-cluster,,,,,35,OpenShift CPU,0.013,0.46,2023-01-0500:00:00Z\n" ) with tempfile.NamedTemporaryFile(mode="w+") as tmp: utils.write_metrics_by_namespace( condensed_metrics_dict=test_metrics_dict, file_name=tmp.name, - report_month="2023-01", + report_metadata=self.report_metadata, su_definitions=SU_DEFINITIONS, rates=RATES, - cluster_name="test-cluster", ) self.assertEqual(tmp.read(), expected_output) @@ -447,22 +461,29 @@ def setUp(self): }, } + self.report_metadata = invoice.ReportMetadata( + report_month="2023-01", + cluster_name="test-cluster", + report_start_time=datetime(2023, 1, 1, tzinfo=UTC), + report_end_time=datetime(2023, 1, 3, tzinfo=UTC), + generated_at=datetime(2023, 1, 5, tzinfo=UTC), + ) + def test_write_metrics_by_namespace_with_ignore_hours(self): expected_output = ( - "Invoice Month,Project - Allocation,Project - Allocation ID,Manager (PI),Cluster Name,Invoice Email,Invoice Address,Institution,Institution - Specific Code,SU Hours (GBhr or SUhr),SU Type,Rate,Cost\n" - "2023-01,namespace1,namespace1,,test-cluster,,,,,12,OpenShift CPU,0.013,0.16\n" - "2023-01,namespace2,namespace2,,test-cluster,,,,,170,OpenShift CPU,0.013,2.21\n" - "2023-01,namespace2,namespace2,,test-cluster,,,,,37,OpenShift GPUA100SXM4,2.078,76.89\n" + "Invoice Month,Report Start Time,Report End Time,Project - Allocation,Project - Allocation ID,Manager (PI),Cluster Name,Invoice Email,Invoice Address,Institution,Institution - Specific Code,SU Hours (GBhr or SUhr),SU Type,Rate,Cost,Generated At\n" + "2023-01,2023-01-0100:00:00Z,2023-01-0300:00:00Z,namespace1,namespace1,,test-cluster,,,,,12,OpenShift CPU,0.013,0.16,2023-01-0500:00:00Z\n" + "2023-01,2023-01-0100:00:00Z,2023-01-0300:00:00Z,namespace2,namespace2,,test-cluster,,,,,170,OpenShift CPU,0.013,2.21,2023-01-0500:00:00Z\n" + "2023-01,2023-01-0100:00:00Z,2023-01-0300:00:00Z,namespace2,namespace2,,test-cluster,,,,,37,OpenShift GPUA100SXM4,2.078,76.89,2023-01-0500:00:00Z\n" ) with tempfile.NamedTemporaryFile(mode="w+") as tmp: utils.write_metrics_by_namespace( condensed_metrics_dict=self.test_metrics_dict, file_name=tmp.name, - report_month="2023-01", + report_metadata=self.report_metadata, rates=RATES, su_definitions=SU_DEFINITIONS, - cluster_name="test-cluster", ignore_hours=self.ignore_times, ) self.assertEqual(tmp.read(), expected_output) diff --git a/openshift_metrics/utils.py b/openshift_metrics/utils.py index 3243247..6a0e4c4 100755 --- a/openshift_metrics/utils.py +++ b/openshift_metrics/utils.py @@ -196,6 +196,8 @@ def write_metrics_by_classes( rows = [] headers = [ "Invoice Month", + "Report Start Time", + "Report End Time", "Project - Allocation", "Project - Allocation ID", "Manager (PI)", @@ -208,6 +210,7 @@ def write_metrics_by_classes( "SU Type", "Rate", "Cost", + "Generated At", ] rows.append(headers) From ccb5c35ba9a6c268ca235b6673b94f1e607ef978 Mon Sep 17 00:00:00 2001 From: Naved Ansari Date: Tue, 2 Dec 2025 10:39:35 -0500 Subject: [PATCH 4/6] Use builtin formatting for isoformat --- openshift_metrics/invoice.py | 6 +++--- openshift_metrics/tests/test_utils.py | 28 +++++++++++++-------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/openshift_metrics/invoice.py b/openshift_metrics/invoice.py index aa69823..8765b01 100644 --- a/openshift_metrics/invoice.py +++ b/openshift_metrics/invoice.py @@ -251,8 +251,8 @@ def generate_invoice_rows(self, metadata: ReportMetadata) -> List[str]: cost = (rate * hours).quantize(Decimal(".01"), rounding=ROUND_HALF_UP) row = [ metadata.report_month, - metadata.report_start_time.strftime("%Y-%m-%d%H:%M:%SZ"), - metadata.report_end_time.strftime("%Y-%m-%d%H:%M:%SZ"), + metadata.report_start_time.isoformat(timespec="seconds"), + metadata.report_end_time.isoformat(timespec="seconds"), self.project, self.project_id, "", # pi @@ -265,7 +265,7 @@ def generate_invoice_rows(self, metadata: ReportMetadata) -> List[str]: su_type, rate, cost, - metadata.generated_at.strftime("%Y-%m-%d%H:%M:%SZ"), + metadata.generated_at.isoformat(timespec="seconds"), ] rows.append(row) return rows diff --git a/openshift_metrics/tests/test_utils.py b/openshift_metrics/tests/test_utils.py index d76adf0..27300a0 100644 --- a/openshift_metrics/tests/test_utils.py +++ b/openshift_metrics/tests/test_utils.py @@ -190,10 +190,10 @@ def test_write_metrics_log(self): expected_output = ( "Invoice Month,Report Start Time,Report End Time,Project - Allocation,Project - Allocation ID,Manager (PI),Cluster Name,Invoice Email,Invoice Address,Institution,Institution - Specific Code,SU Hours (GBhr or SUhr),SU Type,Rate,Cost,Generated At\n" - "2023-01,2023-01-0100:00:00Z,2023-01-0300:00:00Z,namespace1,namespace1,,test-cluster,,,,,1128,OpenShift CPU,0.013,14.66,2023-01-0500:00:00Z\n" - "2023-01,2023-01-0100:00:00Z,2023-01-0300:00:00Z,namespace2,namespace2,,test-cluster,,,,,96,OpenShift CPU,0.013,1.25,2023-01-0500:00:00Z\n" - "2023-01,2023-01-0100:00:00Z,2023-01-0300:00:00Z,namespace2,namespace2,,test-cluster,,,,,48,OpenShift GPUA100,1.803,86.54,2023-01-0500:00:00Z\n" - "2023-01,2023-01-0100:00:00Z,2023-01-0300:00:00Z,namespace2,namespace2,,test-cluster,,,,,48,OpenShift GPUA100SXM4,2.078,99.74,2023-01-0500:00:00Z\n" + "2023-01,2023-01-01T00:00:00+00:00,2023-01-03T00:00:00+00:00,namespace1,namespace1,,test-cluster,,,,,1128,OpenShift CPU,0.013,14.66,2023-01-05T00:00:00+00:00\n" + "2023-01,2023-01-01T00:00:00+00:00,2023-01-03T00:00:00+00:00,namespace2,namespace2,,test-cluster,,,,,96,OpenShift CPU,0.013,1.25,2023-01-05T00:00:00+00:00\n" + "2023-01,2023-01-01T00:00:00+00:00,2023-01-03T00:00:00+00:00,namespace2,namespace2,,test-cluster,,,,,48,OpenShift GPUA100,1.803,86.54,2023-01-05T00:00:00+00:00\n" + "2023-01,2023-01-01T00:00:00+00:00,2023-01-03T00:00:00+00:00,namespace2,namespace2,,test-cluster,,,,,48,OpenShift GPUA100SXM4,2.078,99.74,2023-01-05T00:00:00+00:00\n" ) with tempfile.NamedTemporaryFile(mode="w+") as tmp: @@ -238,8 +238,8 @@ def test_write_metrics_for_vms(self): expected_output = ( "Invoice Month,Report Start Time,Report End Time,Project - Allocation,Project - Allocation ID,Manager (PI),Cluster Name,Invoice Email,Invoice Address,Institution,Institution - Specific Code,SU Hours (GBhr or SUhr),SU Type,Rate,Cost,Generated At\n" - "2023-01,2023-01-0100:00:00Z,2023-01-0300:00:00Z,namespace1,namespace1,,test-cluster,,,,,24,OpenShift GPUA100SXM4,2.078,49.87,2023-01-0500:00:00Z\n" - "2023-01,2023-01-0100:00:00Z,2023-01-0300:00:00Z,namespace1,namespace1,,test-cluster,,,,,24,OpenShift GPUH100,6.04,144.96,2023-01-0500:00:00Z\n" + "2023-01,2023-01-01T00:00:00+00:00,2023-01-03T00:00:00+00:00,namespace1,namespace1,,test-cluster,,,,,24,OpenShift GPUA100SXM4,2.078,49.87,2023-01-05T00:00:00+00:00\n" + "2023-01,2023-01-01T00:00:00+00:00,2023-01-03T00:00:00+00:00,namespace1,namespace1,,test-cluster,,,,,24,OpenShift GPUH100,6.04,144.96,2023-01-05T00:00:00+00:00\n" ) with tempfile.NamedTemporaryFile(mode="w+") as tmp: @@ -338,10 +338,10 @@ def test_write_metrics_log(self): expected_output = ( "Invoice Month,Report Start Time,Report End Time,Project - Allocation,Project - Allocation ID,Manager (PI),Cluster Name,Invoice Email,Invoice Address,Institution,Institution - Specific Code,SU Hours (GBhr or SUhr),SU Type,Rate,Cost,Generated At\n" - "2023-01,2023-01-0100:00:00Z,2023-01-0300:00:00Z,namespace2:noclass,namespace2:noclass,,test-cluster,,,,,96,OpenShift CPU,0.013,1.25,2023-01-0500:00:00Z\n" - "2023-01,2023-01-0100:00:00Z,2023-01-0300:00:00Z,namespace2:math-201,namespace2:math-201,,test-cluster,,,,,96,OpenShift CPU,0.013,1.25,2023-01-0500:00:00Z\n" - "2023-01,2023-01-0100:00:00Z,2023-01-0300:00:00Z,namespace2:math-201,namespace2:math-201,,test-cluster,,,,,24,OpenShift GPUA100,1.803,43.27,2023-01-0500:00:00Z\n" - "2023-01,2023-01-0100:00:00Z,2023-01-0300:00:00Z,namespace2:cs-101,namespace2:cs-101,,test-cluster,,,,,48,OpenShift GPUA100SXM4,2.078,99.74,2023-01-0500:00:00Z\n" + "2023-01,2023-01-01T00:00:00+00:00,2023-01-03T00:00:00+00:00,namespace2:noclass,namespace2:noclass,,test-cluster,,,,,96,OpenShift CPU,0.013,1.25,2023-01-05T00:00:00+00:00\n" + "2023-01,2023-01-01T00:00:00+00:00,2023-01-03T00:00:00+00:00,namespace2:math-201,namespace2:math-201,,test-cluster,,,,,96,OpenShift CPU,0.013,1.25,2023-01-05T00:00:00+00:00\n" + "2023-01,2023-01-01T00:00:00+00:00,2023-01-03T00:00:00+00:00,namespace2:math-201,namespace2:math-201,,test-cluster,,,,,24,OpenShift GPUA100,1.803,43.27,2023-01-05T00:00:00+00:00\n" + "2023-01,2023-01-01T00:00:00+00:00,2023-01-03T00:00:00+00:00,namespace2:cs-101,namespace2:cs-101,,test-cluster,,,,,48,OpenShift GPUA100SXM4,2.078,99.74,2023-01-05T00:00:00+00:00\n" ) with tempfile.NamedTemporaryFile(mode="w+") as tmp: @@ -386,7 +386,7 @@ def test_write_metrics_by_namespace_decimal(self): expected_output = ( "Invoice Month,Report Start Time,Report End Time,Project - Allocation,Project - Allocation ID,Manager (PI),Cluster Name,Invoice Email,Invoice Address,Institution,Institution - Specific Code,SU Hours (GBhr or SUhr),SU Type,Rate,Cost,Generated At\n" - "2023-01,2023-01-0100:00:00Z,2023-01-0300:00:00Z,namespace1,namespace1,,test-cluster,,,,,35,OpenShift CPU,0.013,0.46,2023-01-0500:00:00Z\n" + "2023-01,2023-01-01T00:00:00+00:00,2023-01-03T00:00:00+00:00,namespace1,namespace1,,test-cluster,,,,,35,OpenShift CPU,0.013,0.46,2023-01-05T00:00:00+00:00\n" ) with tempfile.NamedTemporaryFile(mode="w+") as tmp: @@ -472,9 +472,9 @@ def setUp(self): def test_write_metrics_by_namespace_with_ignore_hours(self): expected_output = ( "Invoice Month,Report Start Time,Report End Time,Project - Allocation,Project - Allocation ID,Manager (PI),Cluster Name,Invoice Email,Invoice Address,Institution,Institution - Specific Code,SU Hours (GBhr or SUhr),SU Type,Rate,Cost,Generated At\n" - "2023-01,2023-01-0100:00:00Z,2023-01-0300:00:00Z,namespace1,namespace1,,test-cluster,,,,,12,OpenShift CPU,0.013,0.16,2023-01-0500:00:00Z\n" - "2023-01,2023-01-0100:00:00Z,2023-01-0300:00:00Z,namespace2,namespace2,,test-cluster,,,,,170,OpenShift CPU,0.013,2.21,2023-01-0500:00:00Z\n" - "2023-01,2023-01-0100:00:00Z,2023-01-0300:00:00Z,namespace2,namespace2,,test-cluster,,,,,37,OpenShift GPUA100SXM4,2.078,76.89,2023-01-0500:00:00Z\n" + "2023-01,2023-01-01T00:00:00+00:00,2023-01-03T00:00:00+00:00,namespace1,namespace1,,test-cluster,,,,,12,OpenShift CPU,0.013,0.16,2023-01-05T00:00:00+00:00\n" + "2023-01,2023-01-01T00:00:00+00:00,2023-01-03T00:00:00+00:00,namespace2,namespace2,,test-cluster,,,,,170,OpenShift CPU,0.013,2.21,2023-01-05T00:00:00+00:00\n" + "2023-01,2023-01-01T00:00:00+00:00,2023-01-03T00:00:00+00:00,namespace2,namespace2,,test-cluster,,,,,37,OpenShift GPUA100SXM4,2.078,76.89,2023-01-05T00:00:00+00:00\n" ) with tempfile.NamedTemporaryFile(mode="w+") as tmp: From 02f2a2334e855795570c5794c97428ee51ec5b23 Mon Sep 17 00:00:00 2001 From: Naved Ansari Date: Tue, 2 Dec 2025 11:50:36 -0500 Subject: [PATCH 5/6] Upload the daily report to the S3 location as outlined in the proposal Also use a single timestamp for current_time throughout the report. --- openshift_metrics/merge.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/openshift_metrics/merge.py b/openshift_metrics/merge.py index 2182fd3..70daff6 100644 --- a/openshift_metrics/merge.py +++ b/openshift_metrics/merge.py @@ -213,12 +213,10 @@ def main(): report_start_date = datetime.strptime(report_start_date, "%Y-%m-%d").replace( tzinfo=UTC ) - report_end_date = datetime.strptime(report_end_date, "%Y-%m-%d").replace( - tzinfo=UTC - ) + timedelta(days=1) + report_end_date = datetime.strptime(report_end_date, "%Y-%m-%d").replace(tzinfo=UTC) logger.info( - f"Generating report from {report_start_date} to {report_end_date} for {cluster_name}" + f"Generating report from {report_start_date} to {report_end_date + timedelta(days=1)} for {cluster_name}" ) if report_start_date.month != report_end_date.month: @@ -230,13 +228,13 @@ def main(): ) su_definitions = get_su_definitions(report_month) - + current_time = datetime.now(UTC) report_metadata = invoice.ReportMetadata( report_month=report_month, cluster_name=cluster_name, report_start_time=report_start_date, - report_end_time=report_end_date, - generated_at=datetime.now(UTC), + report_end_time=report_end_date + timedelta(days=1), + generated_at=current_time, ) utils.write_metrics_by_namespace( @@ -269,8 +267,13 @@ def main(): f"Service Invoices/{cluster_name} {report_month}.csv" ) utils.upload_to_s3(invoice_file, S3_INVOICE_BUCKET, primary_location) + report_date = report_end_date.strftime("%Y-%m-%d") + daily_report_location = ( + f"Invoices/{report_month}/Service Invoices/{cluster_name} {report_date}.csv" + ) + utils.upload_to_s3(invoice_file, S3_INVOICE_BUCKET, daily_report_location) - timestamp = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ") + timestamp = current_time.strftime("%Y%m%dT%H%M%SZ") secondary_location = ( f"Invoices/{report_month}/" f"Archive/{cluster_name} {report_month} {timestamp}.csv" From 49c6c0e9470b68cbd265a554e2854833f26ec50d Mon Sep 17 00:00:00 2001 From: Naved Ansari Date: Wed, 3 Dec 2025 13:07:48 -0500 Subject: [PATCH 6/6] Remove logger warning about reports spanning multiple months The new columns will indicate the report start and end date, so it's not strictly necessary to log a warning anymore. Futhermore, since it modified the report_month string, it'll break the call the to get_su_definitions down the line. I could just move that call to it up, but this warning isn't that necessary anyway. --- openshift_metrics/merge.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/openshift_metrics/merge.py b/openshift_metrics/merge.py index 70daff6..ea01980 100644 --- a/openshift_metrics/merge.py +++ b/openshift_metrics/merge.py @@ -219,10 +219,6 @@ def main(): f"Generating report from {report_start_date} to {report_end_date + timedelta(days=1)} for {cluster_name}" ) - if report_start_date.month != report_end_date.month: - logger.warning("The report spans multiple months") - report_month += " to " + datetime.strftime(report_end_date, "%Y-%m") - condensed_metrics_dict = processor.condense_metrics( ["cpu_request", "memory_request", "gpu_request", "gpu_type"] )