Skip to content

Commit a1c6b8c

Browse files
Copilotalvarolopez
andcommitted
fix: Update Prometheus extractor to scan VMs and support query templating
- Changed PrometheusExtractor to inherit from BaseOpenStackExtractor instead of BaseProjectExtractor - Added _get_servers() method to retrieve VMs from Nova for each project - Updated extract() to iterate over VMs and query Prometheus per VM - Added template variable support: {{uuid}} can be used in queries to reference VM UUID - Updated default query to use libvirt domain metrics with UUID templating - Updated all tests to mock the OpenStack base class - Updated documentation with new query examples and VM scanning behavior - Updated sample configuration file with new default query Co-authored-by: alvarolopez <[email protected]>
1 parent fb3b2de commit a1c6b8c

File tree

4 files changed

+208
-82
lines changed

4 files changed

+208
-82
lines changed

caso/extract/prometheus.py

Lines changed: 82 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from oslo_config import cfg
2323
from oslo_log import log
2424

25-
from caso.extract import base
25+
from caso.extract.openstack import base
2626
from caso import record
2727

2828
CONF = cfg.CONF
@@ -35,9 +35,10 @@
3535
),
3636
cfg.StrOpt(
3737
"prometheus_query",
38-
default="sum(rate(node_energy_joules_total[5m])) * 300 / 3600000",
38+
default="sum(rate(libvirt_domain_info_energy_consumption_joules_total"
39+
'{uuid=~"{{uuid}}"}[5m])) * 300 / 3600000',
3940
help="Prometheus query to retrieve energy consumption in kWh. "
40-
"The query should return energy consumption metrics.",
41+
"The query can use {{uuid}} as a template variable for the VM UUID.",
4142
),
4243
cfg.IntOpt(
4344
"prometheus_timeout",
@@ -52,14 +53,13 @@
5253
LOG = log.getLogger(__name__)
5354

5455

55-
class PrometheusExtractor(base.BaseProjectExtractor):
56+
class PrometheusExtractor(base.BaseOpenStackExtractor):
5657
"""A Prometheus extractor for energy consumption metrics in cASO."""
5758

5859
def __init__(self, project, vo):
5960
"""Initialize a Prometheus extractor for a given project."""
60-
super(PrometheusExtractor, self).__init__(project)
61-
self.vo = vo
62-
self.project_id = project
61+
super(PrometheusExtractor, self).__init__(project, vo)
62+
self.nova = self._get_nova_client()
6363

6464
def _query_prometheus(self, query, timestamp=None):
6565
"""Query Prometheus API and return results.
@@ -95,9 +95,35 @@ def _query_prometheus(self, query, timestamp=None):
9595
LOG.error(f"Unexpected error querying Prometheus: {e}")
9696
return None
9797

98-
def _build_energy_record(self, energy_value, measurement_time):
99-
"""Build an energy consumption record.
98+
def _get_servers(self, extract_from):
99+
"""Get all servers for a given date."""
100+
servers = []
101+
limit = 200
102+
marker = None
103+
# Use a marker and iter over results until we do not have more to get
104+
while True:
105+
aux = self.nova.servers.list(
106+
search_opts={
107+
"changes-since": extract_from,
108+
"project_id": self.project_id,
109+
"all_tenants": True,
110+
},
111+
limit=limit,
112+
marker=marker,
113+
)
114+
servers.extend(aux)
115+
116+
if len(aux) < limit:
117+
break
118+
marker = aux[-1].id
119+
120+
return servers
121+
122+
def _build_energy_record(self, vm_uuid, vm_name, energy_value, measurement_time):
123+
"""Build an energy consumption record for a VM.
100124
125+
:param vm_uuid: VM UUID
126+
:param vm_name: VM name
101127
:param energy_value: Energy consumption value in kWh
102128
:param measurement_time: Time of measurement
103129
:returns: EnergyRecord object
@@ -121,47 +147,72 @@ def extract(self, extract_from, extract_to):
121147
"""Extract energy consumption records from Prometheus.
122148
123149
This method queries Prometheus for energy consumption metrics
124-
in the specified time range.
150+
for each VM in the project.
125151
126152
:param extract_from: datetime.datetime object indicating the date to
127153
extract records from
128154
:param extract_to: datetime.datetime object indicating the date to
129155
extract records to
130156
:returns: A list of energy records
131157
"""
158+
# Remove timezone as Nova doesn't expect it
159+
extract_from = extract_from.replace(tzinfo=None)
160+
extract_to = extract_to.replace(tzinfo=None)
161+
132162
records = []
133163

134-
# Query Prometheus at the extract_to timestamp
135-
query = CONF.prometheus.prometheus_query
136-
LOG.debug(
137-
f"Querying Prometheus for project {self.project} " f"with query: {query}"
164+
# Get all servers for the project
165+
LOG.debug(f"Getting servers for project {self.project}")
166+
servers = self._get_servers(extract_from)
167+
168+
LOG.info(
169+
f"Found {len(servers)} VMs for project {self.project}, "
170+
f"querying Prometheus for energy metrics"
138171
)
139172

140-
results = self._query_prometheus(query, extract_to)
173+
# Query Prometheus for each server
174+
query_template = CONF.prometheus.prometheus_query
175+
176+
for server in servers:
177+
vm_uuid = str(server.id)
178+
vm_name = server.name
141179

142-
if results is None:
143-
LOG.warning(
144-
f"No results returned from Prometheus for project {self.project}"
180+
# Replace template variables in the query
181+
query = query_template.replace("{{uuid}}", vm_uuid)
182+
183+
LOG.debug(
184+
f"Querying Prometheus for VM {vm_name} ({vm_uuid}) "
185+
f"with query: {query}"
145186
)
146-
return records
147187

148-
# Process results and create records
149-
for result in results:
150-
value = result.get("value", [])
188+
results = self._query_prometheus(query, extract_to)
151189

152-
if len(value) < 2:
190+
if results is None:
191+
LOG.warning(
192+
f"No results returned from Prometheus for VM "
193+
f"{vm_name} ({vm_uuid})"
194+
)
153195
continue
154196

155-
# value is [timestamp, value_string]
156-
energy_value = float(value[1])
197+
# Process results and create records
198+
for result in results:
199+
value = result.get("value", [])
157200

158-
LOG.debug(
159-
f"Creating energy record: {energy_value} kWh "
160-
f"for project {self.project}"
161-
)
201+
if len(value) < 2:
202+
continue
203+
204+
# value is [timestamp, value_string]
205+
energy_value = float(value[1])
206+
207+
LOG.debug(
208+
f"Creating energy record: {energy_value} kWh "
209+
f"for VM {vm_name} ({vm_uuid})"
210+
)
162211

163-
energy_record = self._build_energy_record(energy_value, extract_to)
164-
records.append(energy_record)
212+
energy_record = self._build_energy_record(
213+
vm_uuid, vm_name, energy_value, extract_to
214+
)
215+
records.append(energy_record)
165216

166217
LOG.info(f"Extracted {len(records)} energy records for project {self.project}")
167218

caso/tests/extract/test_prometheus.py

Lines changed: 89 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,37 @@
3030
class TestPrometheusExtractor:
3131
"""Test the Prometheus extractor."""
3232

33+
@mock.patch("caso.extract.openstack.base.BaseOpenStackExtractor._get_nova_client")
34+
@mock.patch("caso.extract.openstack.base.BaseOpenStackExtractor.__init__")
3335
@mock.patch("caso.extract.prometheus.requests.get")
34-
def test_extract_with_results(self, mock_get):
36+
def test_extract_with_results(self, mock_get, mock_base_init, mock_get_nova):
3537
"""Test extraction with successful Prometheus query."""
3638
# Configure CONF
3739
CONF.set_override("site_name", "TEST-Site")
3840
CONF.set_override("service_name", "TEST-Service")
3941

42+
# Mock the base class __init__ to do nothing
43+
mock_base_init.return_value = None
44+
45+
# Mock Nova client and servers
46+
mock_server1 = mock.Mock()
47+
mock_server1.id = "vm-uuid-1"
48+
mock_server1.name = "test-vm-1"
49+
50+
mock_server2 = mock.Mock()
51+
mock_server2.id = "vm-uuid-2"
52+
mock_server2.name = "test-vm-2"
53+
54+
mock_nova = mock.Mock()
55+
mock_nova.servers.list.return_value = [mock_server1, mock_server2]
56+
mock_get_nova.return_value = mock_nova
57+
58+
# Create extractor and manually set required attributes
59+
extractor = PrometheusExtractor("test-project", "test-vo")
60+
extractor.project = "test-project"
61+
extractor.vo = "test-vo"
62+
extractor.project_id = "test-project-id"
63+
4064
# Mock Prometheus response
4165
mock_response = mock.Mock()
4266
mock_response.json.return_value = {
@@ -53,54 +77,74 @@ def test_extract_with_results(self, mock_get):
5377
mock_response.raise_for_status = mock.Mock()
5478
mock_get.return_value = mock_response
5579

56-
# Create extractor
57-
extractor = PrometheusExtractor("test-project", "test-vo")
58-
5980
# Extract records
6081
extract_from = datetime.datetime(2023, 5, 25, 0, 0, 0)
6182
extract_to = datetime.datetime(2023, 5, 25, 23, 59, 59)
6283
records = extractor.extract(extract_from, extract_to)
6384

64-
# Verify
65-
assert len(records) == 1
85+
# Verify - should create 2 records (one per VM)
86+
assert len(records) == 2
6687
assert records[0].energy_consumption == 125.5
6788
assert records[0].energy_unit == "kWh"
6889
assert records[0].fqan == "test-vo"
6990

70-
@mock.patch("caso.extract.prometheus.requests.get")
71-
def test_extract_with_no_results(self, mock_get):
72-
"""Test extraction when Prometheus returns no results."""
91+
@mock.patch("caso.extract.openstack.base.BaseOpenStackExtractor._get_nova_client")
92+
@mock.patch("caso.extract.openstack.base.BaseOpenStackExtractor.__init__")
93+
def test_extract_with_no_vms(self, mock_base_init, mock_get_nova):
94+
"""Test extraction when there are no VMs."""
7395
# Configure CONF
7496
CONF.set_override("site_name", "TEST-Site")
7597
CONF.set_override("service_name", "TEST-Service")
7698

77-
# Mock Prometheus response with no results
78-
mock_response = mock.Mock()
79-
mock_response.json.return_value = {
80-
"status": "success",
81-
"data": {"result": []},
82-
}
83-
mock_response.raise_for_status = mock.Mock()
84-
mock_get.return_value = mock_response
99+
# Mock the base class __init__ to do nothing
100+
mock_base_init.return_value = None
85101

86-
# Create extractor
102+
# Mock Nova client with no servers
103+
mock_nova = mock.Mock()
104+
mock_nova.servers.list.return_value = []
105+
mock_get_nova.return_value = mock_nova
106+
107+
# Create extractor and manually set required attributes
87108
extractor = PrometheusExtractor("test-project", "test-vo")
109+
extractor.project = "test-project"
110+
extractor.vo = "test-vo"
111+
extractor.project_id = "test-project-id"
88112

89113
# Extract records
90114
extract_from = datetime.datetime(2023, 5, 25, 0, 0, 0)
91115
extract_to = datetime.datetime(2023, 5, 25, 23, 59, 59)
92116
records = extractor.extract(extract_from, extract_to)
93117

94-
# Verify
118+
# Verify - no VMs, no records
95119
assert len(records) == 0
96120

121+
@mock.patch("caso.extract.openstack.base.BaseOpenStackExtractor._get_nova_client")
122+
@mock.patch("caso.extract.openstack.base.BaseOpenStackExtractor.__init__")
97123
@mock.patch("caso.extract.prometheus.requests.get")
98-
def test_extract_with_failed_query(self, mock_get):
124+
def test_extract_with_failed_query(self, mock_get, mock_base_init, mock_get_nova):
99125
"""Test extraction when Prometheus query fails."""
100126
# Configure CONF
101127
CONF.set_override("site_name", "TEST-Site")
102128
CONF.set_override("service_name", "TEST-Service")
103129

130+
# Mock the base class __init__ to do nothing
131+
mock_base_init.return_value = None
132+
133+
# Mock Nova client and servers
134+
mock_server = mock.Mock()
135+
mock_server.id = "vm-uuid-1"
136+
mock_server.name = "test-vm-1"
137+
138+
mock_nova = mock.Mock()
139+
mock_nova.servers.list.return_value = [mock_server]
140+
mock_get_nova.return_value = mock_nova
141+
142+
# Create extractor and manually set required attributes
143+
extractor = PrometheusExtractor("test-project", "test-vo")
144+
extractor.project = "test-project"
145+
extractor.vo = "test-vo"
146+
extractor.project_id = "test-project-id"
147+
104148
# Mock Prometheus error response
105149
mock_response = mock.Mock()
106150
mock_response.json.return_value = {
@@ -110,36 +154,52 @@ def test_extract_with_failed_query(self, mock_get):
110154
mock_response.raise_for_status = mock.Mock()
111155
mock_get.return_value = mock_response
112156

113-
# Create extractor
114-
extractor = PrometheusExtractor("test-project", "test-vo")
115-
116157
# Extract records
117158
extract_from = datetime.datetime(2023, 5, 25, 0, 0, 0)
118159
extract_to = datetime.datetime(2023, 5, 25, 23, 59, 59)
119160
records = extractor.extract(extract_from, extract_to)
120161

121-
# Verify
162+
# Verify - query failed, no records
122163
assert len(records) == 0
123164

165+
@mock.patch("caso.extract.openstack.base.BaseOpenStackExtractor._get_nova_client")
166+
@mock.patch("caso.extract.openstack.base.BaseOpenStackExtractor.__init__")
124167
@mock.patch("caso.extract.prometheus.requests.get")
125168
@mock.patch("caso.extract.prometheus.LOG")
126-
def test_extract_with_request_exception(self, mock_log, mock_get):
169+
def test_extract_with_request_exception(
170+
self, mock_log, mock_get, mock_base_init, mock_get_nova
171+
):
127172
"""Test extraction when request to Prometheus fails."""
128173
# Configure CONF
129174
CONF.set_override("site_name", "TEST-Site")
130175
CONF.set_override("service_name", "TEST-Service")
131176

132-
# Mock request exception
133-
mock_get.side_effect = Exception("Connection error")
177+
# Mock the base class __init__ to do nothing
178+
mock_base_init.return_value = None
179+
180+
# Mock Nova client and servers
181+
mock_server = mock.Mock()
182+
mock_server.id = "vm-uuid-1"
183+
mock_server.name = "test-vm-1"
184+
185+
mock_nova = mock.Mock()
186+
mock_nova.servers.list.return_value = [mock_server]
187+
mock_get_nova.return_value = mock_nova
134188

135-
# Create extractor
189+
# Create extractor and manually set required attributes
136190
extractor = PrometheusExtractor("test-project", "test-vo")
191+
extractor.project = "test-project"
192+
extractor.vo = "test-vo"
193+
extractor.project_id = "test-project-id"
194+
195+
# Mock request exception
196+
mock_get.side_effect = Exception("Connection error")
137197

138198
# Extract records
139199
extract_from = datetime.datetime(2023, 5, 25, 0, 0, 0)
140200
extract_to = datetime.datetime(2023, 5, 25, 23, 59, 59)
141201
records = extractor.extract(extract_from, extract_to)
142202

143-
# Verify
203+
# Verify - exception caught, no records
144204
assert len(records) == 0
145205
mock_log.error.assert_called()

0 commit comments

Comments
 (0)