From e86456c19a7f949aef5e8e991833e2c245b0e280 Mon Sep 17 00:00:00 2001 From: Walter Boring Date: Thu, 3 Sep 2026 10:50:16 -0400 Subject: [PATCH] Add Cinder pool aggregate ID missing metric --- .../collectors/cinderbackend.py | 11 ++ openstack_exporter/tests/__init__.py | 1 + openstack_exporter/tests/unit/__init__.py | 1 + .../tests/unit/collectors/__init__.py | 1 + .../unit/collectors/test_cinderbackend.py | 107 ++++++++++++++++++ 5 files changed, 121 insertions(+) create mode 100644 openstack_exporter/tests/__init__.py create mode 100644 openstack_exporter/tests/unit/__init__.py create mode 100644 openstack_exporter/tests/unit/collectors/__init__.py create mode 100644 openstack_exporter/tests/unit/collectors/test_cinderbackend.py diff --git a/openstack_exporter/collectors/cinderbackend.py b/openstack_exporter/collectors/cinderbackend.py index f785d44..8b44a95 100644 --- a/openstack_exporter/collectors/cinderbackend.py +++ b/openstack_exporter/collectors/cinderbackend.py @@ -139,6 +139,10 @@ def describe(self): 'Cinder Reserved Space Percentage') yield GaugeMetricFamily('cinder_percent_free', 'Cinder Percentage of available space is free.') + yield GaugeMetricFamily( + 'cinder_pool_aggregate_id_missing', + 'Whether the Cinder pool has no aggregate ID.' + ) def _debug_gauge(self, gauge, name, value, shard, backend, pool): LOG.debug(f"({shard}/{backend}/{pool})-{name} = {value}") @@ -265,6 +269,13 @@ def _report_stats(self, shard_name, backend, data, caps, quota_obj): shard_name, backend, pool_name, az ) + yield self.add_gauge_metric_gauge( + 'cinder_pool_aggregate_id_missing', + 'Whether the Cinder pool has no aggregate ID.', + int(not aggregate_id), + shard_name, backend, pool_name, az + ) + if can_overcommit: yield self.add_gauge_metric_gauge( 'cinder_max_oversubscription_ratio', diff --git a/openstack_exporter/tests/__init__.py b/openstack_exporter/tests/__init__.py new file mode 100644 index 0000000..8a5ec59 --- /dev/null +++ b/openstack_exporter/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the OpenStack exporter.""" diff --git a/openstack_exporter/tests/unit/__init__.py b/openstack_exporter/tests/unit/__init__.py new file mode 100644 index 0000000..3b4dfa3 --- /dev/null +++ b/openstack_exporter/tests/unit/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the OpenStack exporter.""" diff --git a/openstack_exporter/tests/unit/collectors/__init__.py b/openstack_exporter/tests/unit/collectors/__init__.py new file mode 100644 index 0000000..94ccef7 --- /dev/null +++ b/openstack_exporter/tests/unit/collectors/__init__.py @@ -0,0 +1 @@ +"""Unit tests for exporter collectors.""" diff --git a/openstack_exporter/tests/unit/collectors/test_cinderbackend.py b/openstack_exporter/tests/unit/collectors/test_cinderbackend.py new file mode 100644 index 0000000..d56bd97 --- /dev/null +++ b/openstack_exporter/tests/unit/collectors/test_cinderbackend.py @@ -0,0 +1,107 @@ +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Tests for the Cinder backend collector.""" + +import unittest + +from openstack_exporter.collectors.cinderbackend import CinderBackendCollector + + +class FakeQuota: + """Minimal quota object required by the collector.""" + + per_volume_gigabytes = 100 + + +class CinderBackendCollectorTest(unittest.TestCase): + """Test Cinder backend metric reporting.""" + + def setUp(self): + """Create a collector without connecting to OpenStack.""" + self.collector = CinderBackendCollector.__new__(CinderBackendCollector) + self.collector.labels = [ + 'backend', 'pool', 'shard', 'availability_zone' + ] + self.collector.aggregate_labels = ['backend', 'pool', 'shard'] + + def _report_pool(self, aggregate_id_marker=None, include_aggregate_id=False): + data = { + 'pool': 'pool-a', + 'can_overcommit': False, + 'total_capacity_gb': 100, + 'available_capacity_gb': 90, + 'free_capacity_gb': 80, + 'virtual_free_capacity_gb': 80, + 'allocated_capacity_gb': 20, + 'max_over_subscription_ratio': 1.0, + 'overcommit_ratio': 0.2, + 'reserved_percentage': 0, + } + caps = { + 'backend_state': 'up', + 'pool_state': 'up', + 'volume_backend_name': 'backend-a', + 'driver_version': '1', + 'custom_attributes': {}, + } + if include_aggregate_id: + data['aggregate_id'] = aggregate_id_marker + return list(self.collector._report_stats( + 'shard-a', 'backend-a', data, caps, FakeQuota())) + + @staticmethod + def _metric(metrics, name): + return next(metric for metric in metrics if metric.name == name) + + def test_describe_registers_aggregate_id_missing_metric(self): + """Register the aggregate ID missing metric in describe output.""" + names = [metric.name for metric in self.collector.describe()] + + self.assertIn('cinder_pool_aggregate_id_missing', names) + + def test_real_aggregate_id_is_not_missing(self): + """Report zero when a pool has a real aggregate ID.""" + metrics = self._report_pool('aggregate-a', include_aggregate_id=True) + + aggregate_id = self._metric(metrics, 'cinder_aggregate_id') + missing = self._metric(metrics, 'cinder_pool_aggregate_id_missing') + self.assertEqual('aggregate-a', aggregate_id.samples[0].labels['aggregate_id']) + self.assertEqual(0, missing.samples[0].value) + self.assertEqual({ + 'backend': 'backend-a', + 'pool': 'pool-a', + 'shard': 'shard-a', + 'availability_zone': 'unknown', + }, missing.samples[0].labels) + + def test_missing_aggregate_id_is_reported(self): + """Report one for null and empty aggregate IDs.""" + for marker in (None, ''): + with self.subTest(marker=marker): + metrics = self._report_pool(marker, include_aggregate_id=True) + names = [metric.name for metric in metrics] + + self.assertNotIn('cinder_aggregate_id', names) + missing = self._metric(metrics, 'cinder_pool_aggregate_id_missing') + self.assertEqual(1, missing.samples[0].value) + + def test_absent_aggregate_id_is_reported(self): + """Report one when aggregate ID is absent.""" + metrics = self._report_pool() + + names = [metric.name for metric in metrics] + self.assertNotIn('cinder_aggregate_id', names) + missing = self._metric(metrics, 'cinder_pool_aggregate_id_missing') + self.assertEqual(1, missing.samples[0].value)