From 53b335aacac67d86eae9adee53fed19b68733ae9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jarom=C3=ADr=20Wysoglad?= Date: Tue, 5 Sep 2023 14:54:33 +0200 Subject: [PATCH] Add automated unit testing and a set of tests (#9) * Add unit testing * Fix code according to CI This includes: - formating changes - rewording of some doc strings - adding support to {label!~'value'} in rbac * Add unit tests automation * Fix CI automation * Add requirements.txt --- .github/workflows/unit_tests.yml | 23 + observabilityclient/plugin.py | 6 +- observabilityclient/prometheus_client.py | 30 +- observabilityclient/tests/unit/__init__.py | 0 observabilityclient/tests/unit/test_cli.py | 142 +++++ .../tests/unit/test_prometheus_client.py | 515 ++++++++++++++++++ .../tests/unit/test_python_api.py | 126 +++++ observabilityclient/tests/unit/test_rbac.py | 146 +++++ observabilityclient/tests/unit/test_utils.py | 126 +++++ observabilityclient/utils/metric_utils.py | 11 +- observabilityclient/v1/base.py | 1 + observabilityclient/v1/cli.py | 70 +-- observabilityclient/v1/client.py | 2 +- observabilityclient/v1/python_api.py | 18 +- observabilityclient/v1/rbac.py | 44 +- requirements.txt | 3 + setup.cfg | 12 + tools/install_deps.sh | 25 + tox.ini | 47 ++ 19 files changed, 1260 insertions(+), 87 deletions(-) create mode 100644 .github/workflows/unit_tests.yml create mode 100644 observabilityclient/tests/unit/__init__.py create mode 100644 observabilityclient/tests/unit/test_cli.py create mode 100644 observabilityclient/tests/unit/test_prometheus_client.py create mode 100644 observabilityclient/tests/unit/test_python_api.py create mode 100644 observabilityclient/tests/unit/test_rbac.py create mode 100644 observabilityclient/tests/unit/test_utils.py create mode 100644 requirements.txt create mode 100755 tools/install_deps.sh create mode 100644 tox.ini diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml new file mode 100644 index 0000000..a866d90 --- /dev/null +++ b/.github/workflows/unit_tests.yml @@ -0,0 +1,23 @@ +name: unit_tests + +on: + pull_request: + +jobs: + test: + runs-on: ubuntu-22.04 + timeout-minutes: 30 + strategy: + matrix: + env: + - pep8 + - py39 + - py311 + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Install dependencies + run: ./tools/install_deps.sh + - name: Run tox + run: tox -e ${{ matrix.env }} diff --git a/observabilityclient/plugin.py b/observabilityclient/plugin.py index e51f712..77f993e 100644 --- a/observabilityclient/plugin.py +++ b/observabilityclient/plugin.py @@ -12,7 +12,7 @@ # License for the specific language governing permissions and limitations # under the License. -"""OpenStackClient Plugin interface""" +"""OpenStackClient Plugin interface.""" from osc_lib import utils @@ -26,7 +26,7 @@ API_VERSIONS = { def make_client(instance): - """Returns a client to the ClientManager + """Return a client to the ClientManager. Called to instantiate the requested client version. instance has any available auth info that may be required to prepare the client. @@ -47,7 +47,7 @@ def make_client(instance): def build_option_parser(parser): - """Hook to add global options + """Add global options. Called from openstackclient.shell.OpenStackShell.__init__() after the builtin parser has been initialized. This is diff --git a/observabilityclient/prometheus_client.py b/observabilityclient/prometheus_client.py index bba5b26..1e0ad95 100644 --- a/observabilityclient/prometheus_client.py +++ b/observabilityclient/prometheus_client.py @@ -13,6 +13,7 @@ # under the License. import logging + import requests @@ -95,13 +96,12 @@ class PrometheusAPIClient: return decoded def query(self, query): - """Sends custom queries to Prometheus + """Send custom queries to Prometheus. :param query: the query to send :type query: str """ - - LOG.debug(f"Querying prometheus with query: {query}") + LOG.debug("Querying prometheus with query: %s", query) decoded = self._get("query", dict(query=query)) if decoded['data']['resultType'] == 'vector': @@ -111,38 +111,35 @@ class PrometheusAPIClient: return result def series(self, matches): - """Queries the /series/ endpoint of prometheus + """Query the /series/ endpoint of prometheus. :param matches: List of matches to send as parameters :type matches: [str] """ - - LOG.debug(f"Querying prometheus for series with matches: {matches}") + LOG.debug("Querying prometheus for series with matches: %s", matches) decoded = self._get("series", {"match[]": matches}) return decoded['data'] def labels(self): - """Queries the /labels/ endpoint of prometheus, returns list of labels + """Query the /labels/ endpoint of prometheus, returns list of labels. There isn't a way to tell prometheus to restrict which labels to return. It's not possible to enforce rbac with this for example. """ - LOG.debug("Querying prometheus for labels") decoded = self._get("labels") return decoded['data'] def label_values(self, label): - """Queries prometheus for values of a specified label. + """Query prometheus for values of a specified label. :param label: Name of label for which to return values :type label: str """ - - LOG.debug(f"Querying prometheus for the values of label: {label}") + LOG.debug("Querying prometheus for the values of label: %s", label) decoded = self._get(f"label/{label}/values") return decoded['data'] @@ -152,7 +149,7 @@ class PrometheusAPIClient: # --------- def delete(self, matches, start=None, end=None): - """Deletes some metrics from prometheus + """Delete some metrics from prometheus. :param matches: List of matches, that specify which metrics to delete :type matches [str] @@ -168,8 +165,7 @@ class PrometheusAPIClient: # way to know if anything got actually deleted. # It does however return 500 code and error msg # if the admin APIs are disabled. - - LOG.debug(f"Deleting metrics from prometheus matching: {matches}") + LOG.debug("Deleting metrics from prometheus matching: %s", matches) try: self._post("admin/tsdb/delete_series", {"match[]": matches, "start": start, @@ -181,8 +177,7 @@ class PrometheusAPIClient: raise exc def clean_tombstones(self): - """Asks prometheus to clean tombstones""" - + """Ask prometheus to clean tombstones.""" LOG.debug("Cleaning tombstones from prometheus") try: self._post("admin/tsdb/clean_tombstones") @@ -193,8 +188,7 @@ class PrometheusAPIClient: raise exc def snapshot(self): - """Creates a snapshot and returns the file name containing the data""" - + """Create a snapshot and return the file name containing the data.""" LOG.debug("Taking prometheus data snapshot") ret = self._post("admin/tsdb/snapshot") return ret["data"]["name"] diff --git a/observabilityclient/tests/unit/__init__.py b/observabilityclient/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/observabilityclient/tests/unit/test_cli.py b/observabilityclient/tests/unit/test_cli.py new file mode 100644 index 0000000..ee698a1 --- /dev/null +++ b/observabilityclient/tests/unit/test_cli.py @@ -0,0 +1,142 @@ +# Copyright 2023 Red Hat, Inc. +# +# 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. + +from unittest import mock + +import testtools + +from observabilityclient.prometheus_client import PrometheusMetric +from observabilityclient.utils import metric_utils +from observabilityclient.v1 import cli + + +class CliTest(testtools.TestCase): + def setUp(self): + super(CliTest, self).setUp() + self.client = mock.Mock() + self.client.query = mock.Mock() + + def test_list(self): + args_enabled = {'disable_rbac': False} + args_disabled = {'disable_rbac': True} + + metric_names = ['name1', 'name2', 'name3'] + expected = (['metric_name'], [['name1'], ['name2'], ['name3']]) + cli_list = cli.List(mock.Mock(), mock.Mock()) + + with (mock.patch.object(metric_utils, 'get_client', + return_value=self.client), + mock.patch.object(self.client.query, 'list', + return_value=metric_names) as m): + ret1 = cli_list.take_action(args_enabled) + m.assert_called_with(disable_rbac=False) + + ret2 = cli_list.take_action(args_disabled) + m.assert_called_with(disable_rbac=True) + + self.assertEqual(ret1, expected) + self.assertEqual(ret2, expected) + + def test_show(self): + args_enabled = {'name': 'metric_name', 'disable_rbac': False} + args_disabled = {'name': 'metric_name', 'disable_rbac': True} + + metric = { + 'value': [123456, 12], + 'metric': {'label1': 'value1'} + } + prom_metric = [PrometheusMetric(metric)] + expected = ['label1', 'value'], [['value1', 12]] + + cli_show = cli.Show(mock.Mock(), mock.Mock()) + + with (mock.patch.object(metric_utils, 'get_client', + return_value=self.client), + mock.patch.object(self.client.query, 'show', + return_value=prom_metric) as m): + + ret1 = cli_show.take_action(args_enabled) + m.assert_called_with('metric_name', disable_rbac=False) + + ret2 = cli_show.take_action(args_disabled) + m.assert_called_with('metric_name', disable_rbac=True) + + self.assertEqual(ret1, expected) + self.assertEqual(ret2, expected) + + def test_query(self): + query = ("some_query{label!~'not_this_value'} - " + "sum(second_metric{label='this'})") + args_enabled = {'query': query, 'disable_rbac': False} + args_disabled = {'query': query, 'disable_rbac': True} + + metric = { + 'value': [123456, 12], + 'metric': {'label1': 'value1'} + } + + prom_metric = [PrometheusMetric(metric)] + expected = ['label1', 'value'], [['value1', 12]] + + cli_query = cli.Query(mock.Mock(), mock.Mock()) + + with (mock.patch.object(metric_utils, 'get_client', + return_value=self.client), + mock.patch.object(self.client.query, 'query', + return_value=prom_metric) as m): + + ret1 = cli_query.take_action(args_enabled) + m.assert_called_with(query, disable_rbac=False) + + ret2 = cli_query.take_action(args_disabled) + m.assert_called_with(query, disable_rbac=True) + + self.assertEqual(ret1, expected) + self.assertEqual(ret2, expected) + + def test_delete(self): + matches = "some_label_name" + args = {'matches': matches, 'start': 0, 'end': 10} + + cli_delete = cli.Delete(mock.Mock(), mock.Mock()) + + with (mock.patch.object(metric_utils, 'get_client', + return_value=self.client), + mock.patch.object(self.client.query, 'delete') as m): + + cli_delete.take_action(args) + m.assert_called_with(matches, 0, 10) + + def test_clean_combstones(self): + cli_clean_tombstones = cli.CleanTombstones(mock.Mock(), mock.Mock()) + + with (mock.patch.object(metric_utils, 'get_client', + return_value=self.client), + mock.patch.object(self.client.query, 'clean_tombstones') as m): + + cli_clean_tombstones.take_action({}) + m.assert_called_once() + + def test_snapshot(self): + cli_snapshot = cli.Snapshot(mock.Mock(), mock.Mock()) + file_name = 'some_file_name' + + with (mock.patch.object(metric_utils, 'get_client', + return_value=self.client), + mock.patch.object(self.client.query, 'snapshot', + return_value=file_name) as m): + + ret = cli_snapshot.take_action({}) + m.assert_called_once() + self.assertEqual(ret, (["Snapshot file name"], [[file_name]])) diff --git a/observabilityclient/tests/unit/test_prometheus_client.py b/observabilityclient/tests/unit/test_prometheus_client.py new file mode 100644 index 0000000..ecba20d --- /dev/null +++ b/observabilityclient/tests/unit/test_prometheus_client.py @@ -0,0 +1,515 @@ +# Copyright 2023 Red Hat, Inc. +# +# 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. + +from unittest import mock + +import requests + +import testtools + +from observabilityclient import prometheus_client as client + + +class MetricListMatcher(testtools.Matcher): + def __init__(self, expected): + self.expected = expected + + def __str__(self): + return ("Matches Lists of metrics as returned " + "by prometheus_client.PremetheusAPIClient.query") + + def metric_to_str(self, metric): + return (f"Labels: {metric.labels}\n" + f"Timestamp: {metric.timestamp}\n" + f"Value: {metric.value}") + + def match(self, observed): + if len(self.expected) != len(observed): + description = (f"len(expected) != len(observed) because " + f"{len(self.expected)} != {len(observed)}") + return testtools.matchers.Mismatch(description=description) + + for e in self.expected: + for o in observed: + if (e.timestamp == o.timestamp and + e.value == o.value and + e.labels == o.labels): + observed.remove(o) + break + + if len(observed) != 0: + description = "Couldn't match the following metrics:\n" + for o in observed: + description += self.metric_to_str(o) + "\n\n" + return testtools.matchers.Mismatch(description=description) + return None + + +class PrometheusAPIClientTestBase(testtools.TestCase): + def setUp(self): + super(PrometheusAPIClientTestBase, self).setUp() + + class GoodResponse(object): + def __init__(self): + self.status_code = 200 + + def json(self): + return {"status": "success"} + + class BadResponse(object): + def __init__(self): + self.status_code = 500 + + def json(self): + return {"status": "error", "error": "test_error"} + + class NoContentResponse(object): + def __init__(self): + self.status_code = 204 + + def json(self): + raise requests.exceptions.JSONDecodeError("No content") + + +class PrometheusAPIClientTest(PrometheusAPIClientTestBase): + def test_get(self): + url = "test" + expected_url = "http://localhost:9090/api/v1/test" + + params = {"query": "ceilometer_image_size{publisher='localhost'}"} + expected_params = params + + return_value = self.GoodResponse() + with mock.patch.object(requests.Session, 'get', + return_value=return_value) as m: + c = client.PrometheusAPIClient("localhost:9090") + c._get(url, params) + + m.assert_called_with(expected_url, + params=expected_params, + headers={'Accept': 'application/json'}) + + def test_get_error(self): + url = "test" + params = {"query": "ceilometer_image_size{publisher='localhost'}"} + + return_value = self.BadResponse() + with mock.patch.object(requests.Session, 'get', + return_value=return_value): + c = client.PrometheusAPIClient("localhost:9090") + self.assertRaises(client.PrometheusAPIClientError, + c._get, url, params) + + return_value = self.NoContentResponse() + with mock.patch.object(requests.Session, 'get', + return_value=return_value): + c = client.PrometheusAPIClient("localhost:9090") + self.assertRaises(client.PrometheusAPIClientError, + c._get, url, params) + + def test_post(self): + url = "test" + expected_url = "http://localhost:9090/api/v1/test" + + params = {"query": "ceilometer_image_size{publisher='localhost'}"} + expected_params = params + + return_value = self.GoodResponse() + with mock.patch.object(requests.Session, 'post', + return_value=return_value) as m: + c = client.PrometheusAPIClient("localhost:9090") + c._post(url, params) + + m.assert_called_with(expected_url, + params=expected_params, + headers={'Accept': 'application/json'}) + + def test_post_error(self): + url = "test" + params = {"query": "ceilometer_image_size{publisher='localhost'}"} + + return_value = self.BadResponse() + with mock.patch.object(requests.Session, 'post', + return_value=return_value): + c = client.PrometheusAPIClient("localhost:9090") + self.assertRaises(client.PrometheusAPIClientError, + c._post, url, params) + + return_value = self.NoContentResponse() + with mock.patch.object(requests.Session, 'post', + return_value=return_value): + c = client.PrometheusAPIClient("localhost:9090") + self.assertRaises(client.PrometheusAPIClientError, + c._post, url, params) + + +class PrometheusAPIClientQueryTest(PrometheusAPIClientTestBase): + def setUp(self): + super().setUp() + + class GoodQueryResponse(PrometheusAPIClientTestBase.GoodResponse): + def __init__(self): + super().__init__() + self.result1 = { + "metric": { + "__name__": "test1", + }, + "value": [103254, "1"] + } + self.result2 = { + "metric": { + "__name__": "test2", + }, + "value": [103255, "2"] + } + self.expected = [client.PrometheusMetric(self.result1), + client.PrometheusMetric(self.result2)] + + def json(self): + return { + "status": "success", + "data": { + "resultType": "vector", + "result": [self.result1, self.result2] + } + } + + class EmptyQueryResponse(PrometheusAPIClientTestBase.GoodResponse): + def __init__(self): + super().__init__() + self.expected = [] + + def json(self): + return { + "status": "success", + "data": { + "resultType": "vector", + "result": [] + } + } + + def test_query(self): + query = "ceilometer_image_size{publisher='localhost.localdomain'}" + + matcher = MetricListMatcher(self.GoodQueryResponse().expected) + return_value = self.GoodQueryResponse().json() + with mock.patch.object(client.PrometheusAPIClient, '_get', + return_value=return_value) as m: + c = client.PrometheusAPIClient("localhost:9090") + ret = c.query(query) + + m.assert_called_with("query", {"query": query}) + self.assertThat(ret, matcher) + + return_value = self.EmptyQueryResponse().json() + with mock.patch.object(client.PrometheusAPIClient, '_get', + return_value=return_value) as m: + c = client.PrometheusAPIClient("localhost:9090") + ret = c.query(query) + + self.assertEqual(self.EmptyQueryResponse().expected, ret) + + def test_query_error(self): + query = "ceilometer_image_size{publisher='localhost.localdomain'}" + client_exception = client.PrometheusAPIClientError(self.BadResponse()) + + with mock.patch.object(client.PrometheusAPIClient, '_get', + side_effect=client_exception): + c = client.PrometheusAPIClient("localhost:9090") + + self.assertRaises(client.PrometheusAPIClientError, c.query, query) + + +class PrometheusAPIClientSeriesTest(PrometheusAPIClientTestBase): + def setUp(self): + super().setUp() + + class GoodSeriesResponse(PrometheusAPIClientTestBase.GoodResponse): + def __init__(self): + super().__init__() + self.data = [{ + "__name__": "up", + "job": "prometheus", + "instance": "localhost:9090" + }, { + "__name__": "up", + "job": "node", + "instance": "localhost:9091" + }, { + "__name__": "process_start_time_seconds", + "job": "prometheus", + "instance": "localhost:9090" + }] + self.expected = self.data + + def json(self): + return { + "status": "success", + "data": self.data + } + + class EmptySeriesResponse(PrometheusAPIClientTestBase.GoodResponse): + def __init__(self): + super().__init__() + self.data = [] + self.expected = self.data + + def json(self): + return { + "status": "success", + "data": self.data + } + + def test_series(self): + matches = ["up", "ceilometer_image_size"] + + return_value = self.GoodSeriesResponse().json() + with mock.patch.object(client.PrometheusAPIClient, '_get', + return_value=return_value) as m: + c = client.PrometheusAPIClient("localhost:9090") + ret = c.series(matches) + + m.assert_called_with("series", {"match[]": matches}) + self.assertEqual(ret, self.GoodSeriesResponse().data) + + return_value = self.EmptySeriesResponse().json() + with mock.patch.object(client.PrometheusAPIClient, '_get', + return_value=return_value) as m: + c = client.PrometheusAPIClient("localhost:9090") + ret = c.series(matches) + + m.assert_called_with("series", {"match[]": matches}) + self.assertEqual(ret, self.EmptySeriesResponse().data) + + def test_series_error(self): + matches = ["up", "ceilometer_image_size"] + client_exception = client.PrometheusAPIClientError(self.BadResponse()) + + with mock.patch.object(client.PrometheusAPIClient, '_get', + side_effect=client_exception): + c = client.PrometheusAPIClient("localhost:9090") + + self.assertRaises(client.PrometheusAPIClientError, + c.series, + matches) + + +class PrometheusAPIClientLabelsTest(PrometheusAPIClientTestBase): + def setUp(self): + super().setUp() + + class GoodLabelsResponse(PrometheusAPIClientTestBase.GoodResponse): + def __init__(self): + super().__init__() + self.labels = ["up", "job", "project_id"] + + def json(self): + return { + "status": "success", + "data": self.labels + } + + def test_labels(self): + return_value = self.GoodLabelsResponse().json() + with mock.patch.object(client.PrometheusAPIClient, '_get', + return_value=return_value) as m: + c = client.PrometheusAPIClient("localhost:9090") + ret = c.labels() + + m.assert_called_with("labels") + self.assertEqual(ret, self.GoodLabelsResponse().labels) + + def test_labels_error(self): + client_exception = client.PrometheusAPIClientError(self.BadResponse()) + with mock.patch.object(client.PrometheusAPIClient, '_get', + side_effect=client_exception): + c = client.PrometheusAPIClient("localhost:9090") + + self.assertRaises(client.PrometheusAPIClientError, c.labels) + + +class PrometheusAPIClientLabelValuesTest(PrometheusAPIClientTestBase): + def setUp(self): + super().setUp() + + class GoodLabelValuesResponse(PrometheusAPIClientTestBase.GoodResponse): + def __init__(self): + super().__init__() + self.values = ["prometheus", "some_other_value"] + + def json(self): + return { + "status": "success", + "data": self.values + } + + class EmptyLabelValuesResponse(PrometheusAPIClientTestBase.GoodResponse): + def __init__(self): + super().__init__() + self.values = [] + + def json(self): + return { + "status": "success", + "data": self.values + } + + def test_label_values(self): + label_name = "job" + + return_value = self.GoodLabelValuesResponse().json() + with mock.patch.object(client.PrometheusAPIClient, '_get', + return_value=return_value) as m: + c = client.PrometheusAPIClient("localhost:9090") + ret = c.label_values(label_name) + + m.assert_called_with(f"label/{label_name}/values") + self.assertEqual(ret, self.GoodLabelValuesResponse().values) + + return_value = self.EmptyLabelValuesResponse().json() + with mock.patch.object(client.PrometheusAPIClient, '_get', + return_value=return_value) as m: + c = client.PrometheusAPIClient("localhost:9090") + ret = c.label_values(label_name) + + m.assert_called_with(f"label/{label_name}/values") + self.assertEqual(ret, self.EmptyLabelValuesResponse().values) + + def test_label_values_error(self): + label_name = "job" + client_exception = client.PrometheusAPIClientError(self.BadResponse()) + + with mock.patch.object(client.PrometheusAPIClient, '_get', + side_effect=client_exception): + c = client.PrometheusAPIClient("localhost:9090") + + self.assertRaises(client.PrometheusAPIClientError, + c.label_values, + label_name) + + +class PrometheusAPIClientDeleteTest(PrometheusAPIClientTestBase): + def setUp(self): + super().setUp() + + class GoodDeleteResponse(PrometheusAPIClientTestBase.NoContentResponse): + pass + + def test_delete(self): + matches = ["{job='prometheus'}", "up"] + start = 1 + end = 12 + resp = self.GoodDeleteResponse() + post_exception = client.PrometheusAPIClientError(resp) + + with mock.patch.object(client.PrometheusAPIClient, '_post', + side_effect=post_exception) as m: + c = client.PrometheusAPIClient("localhost:9090") + # _post is expected to raise an exception. It's expected + # that the exception is caught inside delete. This + # test should run without exception getting out of delete + try: + c.delete(matches, start, end) + except Exception as ex: # noqa: B902 + self.fail("Exception risen by delete: " + ex) + + m.assert_called_with("admin/tsdb/delete_series", + {"match[]": matches, + "start": start, + "end": end}) + + def test_delete_error(self): + matches = ["{job='prometheus'}", "up"] + client_exception = client.PrometheusAPIClientError(self.BadResponse()) + + with mock.patch.object(client.PrometheusAPIClient, '_post', + side_effect=client_exception): + c = client.PrometheusAPIClient("localhost:9090") + + self.assertRaises(client.PrometheusAPIClientError, + c.delete, + matches) + + +class PrometheusAPIClientCleanTombstonesTest(PrometheusAPIClientTestBase): + def setUp(self): + super().setUp() + + class GoodCleanTombResponse(PrometheusAPIClientTestBase.NoContentResponse): + pass + + def test_clean_tombstones(self): + resp = self.GoodCleanTombResponse() + post_exception = client.PrometheusAPIClientError(resp) + + with mock.patch.object(client.PrometheusAPIClient, '_post', + side_effect=post_exception) as m: + c = client.PrometheusAPIClient("localhost:9090") + # _post is expected to raise an exception. It's expected + # that the exception is caught inside clean_tombstones. This + # test should run without exception getting out of clean_tombstones + try: + c.clean_tombstones() + except Exception as ex: # noqa: B902 + self.fail("Exception risen by clean_tombstones: " + ex) + + m.assert_called_with("admin/tsdb/clean_tombstones") + + def test_snapshot_error(self): + client_exception = client.PrometheusAPIClientError(self.BadResponse()) + + with mock.patch.object(client.PrometheusAPIClient, '_post', + side_effect=client_exception): + c = client.PrometheusAPIClient("localhost:9090") + + self.assertRaises(client.PrometheusAPIClientError, + c.clean_tombstones) + + +class PrometheusAPIClientSnapshotTest(PrometheusAPIClientTestBase): + def setUp(self): + super().setUp() + + class GoodSnapshotResponse(PrometheusAPIClientTestBase.NoContentResponse): + def __init__(self): + super().__init__() + self.filename = "somefilename" + + def json(self): + return { + "status": "success", + "data": { + "name": self.filename + } + } + + def test_snapshot(self): + return_value = self.GoodSnapshotResponse().json() + with mock.patch.object(client.PrometheusAPIClient, '_post', + return_value=return_value) as m: + c = client.PrometheusAPIClient("localhost:9090") + ret = c.snapshot() + + m.assert_called_with("admin/tsdb/snapshot") + self.assertEqual(ret, self.GoodSnapshotResponse().filename) + + def test_snapshot_error(self): + client_exception = client.PrometheusAPIClientError(self.BadResponse()) + + with mock.patch.object(client.PrometheusAPIClient, '_post', + side_effect=client_exception): + c = client.PrometheusAPIClient("localhost:9090") + + self.assertRaises(client.PrometheusAPIClientError, + c.snapshot) diff --git a/observabilityclient/tests/unit/test_python_api.py b/observabilityclient/tests/unit/test_python_api.py new file mode 100644 index 0000000..472b8cb --- /dev/null +++ b/observabilityclient/tests/unit/test_python_api.py @@ -0,0 +1,126 @@ +# Copyright 2023 Red Hat, Inc. +# +# 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. + +from unittest import mock + +import testtools + +from observabilityclient import prometheus_client +from observabilityclient.tests.unit.test_prometheus_client import ( + MetricListMatcher +) +from observabilityclient.v1 import python_api +from observabilityclient.v1 import rbac + + +class QueryManagerTest(testtools.TestCase): + def setUp(self): + super(QueryManagerTest, self).setUp() + self.client = mock.Mock() + prom_client = prometheus_client.PrometheusAPIClient("somehost") + self.client.prometheus_client = prom_client + + self.rbac = mock.Mock(wraps=rbac.Rbac(self.client, mock.Mock())) + self.rbac.default_labels = {'project': 'project_id'} + self.rbac.rbac_init_succesful = True + + self.manager = python_api.QueryManager(self.client) + + self.client.rbac = self.rbac + self.client.query = self.manager + + def test_list(self): + returned_by_prom = {'data': ['metric1', 'test42', 'abc2']} + expected = ['abc2', 'metric1', 'test42'] + + with mock.patch.object(prometheus_client.PrometheusAPIClient, '_get', + return_value=returned_by_prom): + ret1 = self.manager.list() + ret2 = self.manager.list(disable_rbac=True) + + self.assertEqual(expected, ret1) + self.assertEqual(expected, ret2) + + def test_show(self): + query = 'some_metric' + returned_by_prom = { + 'data': { + 'resultType': 'non-vector' + }, + 'value': [1234567, 42], + 'metric': { + 'label': 'label_value' + } + } + expected = [prometheus_client.PrometheusMetric(returned_by_prom)] + expected_matcher = MetricListMatcher(expected) + with mock.patch.object(prometheus_client.PrometheusAPIClient, '_get', + return_value=returned_by_prom): + ret1 = self.manager.show(query) + self.rbac.append_rbac.assert_called_with(query, + disable_rbac=False) + + ret2 = self.manager.show(query, disable_rbac=True) + self.rbac.append_rbac.assert_called_with(query, + disable_rbac=True) + + self.assertThat(ret1, expected_matcher) + self.assertThat(ret2, expected_matcher) + + def test_query(self): + query = 'some_metric' + returned_by_prom = { + 'data': { + 'resultType': 'non-vector' + }, + 'value': [1234567, 42], + 'metric': { + 'label': 'label_value' + } + } + expected = [prometheus_client.PrometheusMetric(returned_by_prom)] + expected_matcher = MetricListMatcher(expected) + with mock.patch.object(prometheus_client.PrometheusAPIClient, '_get', + return_value=returned_by_prom): + ret1 = self.manager.query(query) + self.rbac.enrich_query.assert_called_with(query, + disable_rbac=False) + + ret2 = self.manager.query(query, disable_rbac=True) + self.rbac.enrich_query.assert_called_with(query, + disable_rbac=True) + + self.assertThat(ret1, expected_matcher) + self.assertThat(ret2, expected_matcher) + + def test_delete(self): + matches = "some_metric" + start = 0 + end = 100 + with mock.patch.object(prometheus_client.PrometheusAPIClient, + 'delete') as m: + self.manager.delete(matches, start, end) + m.assert_called_with(matches, start, end) + + def test_clean_tombstones(self): + with mock.patch.object(prometheus_client.PrometheusAPIClient, + 'clean_tombstones') as m: + self.manager.clean_tombstones() + m.assert_called_once() + + def test_snapshot(self): + with mock.patch.object(prometheus_client.PrometheusAPIClient, + 'snapshot') as m: + self.manager.snapshot() + m.assert_called_once() diff --git a/observabilityclient/tests/unit/test_rbac.py b/observabilityclient/tests/unit/test_rbac.py new file mode 100644 index 0000000..fee81f7 --- /dev/null +++ b/observabilityclient/tests/unit/test_rbac.py @@ -0,0 +1,146 @@ +# Copyright 2023 Red Hat, Inc. +# +# 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. + +from unittest import mock + +from keystoneauth1.exceptions.auth_plugins import MissingAuthPlugin +from keystoneauth1 import session + +import testtools + +from observabilityclient.v1 import rbac + + +class RbacTest(testtools.TestCase): + def setUp(self): + super(RbacTest, self).setUp() + self.rbac = rbac.Rbac(mock.Mock(), mock.Mock()) + self.rbac.project_id = "secret_id" + self.rbac.default_labels = { + "project": self.rbac.project_id + } + + def test_constructor(self): + with mock.patch.object(session.Session, 'get_project_id', + return_value="123"): + r = rbac.Rbac("client", session.Session(), False) + self.assertEqual(r.project_id, "123") + self.assertEqual(r.default_labels, { + "project": "123" + }) + + def test_constructor_error(self): + with mock.patch.object(session.Session, 'get_project_id', + side_effect=MissingAuthPlugin()): + r = rbac.Rbac("client", session.Session(), False) + self.assertEqual(r.project_id, None) + + def test_enrich_query(self): + test_cases = [ + ( + "test_query", + f"test_query{{project='{self.rbac.project_id}'}}" + ), ( + "test_query{somelabel='value'}", + + (f"test_query{{somelabel='value', " + f"project='{self.rbac.project_id}'}}") + ), ( + "test_query{somelabel='value', label2='value2'}", + + (f"test_query{{somelabel='value', label2='value2', " + f"project='{self.rbac.project_id}'}}") + ), ( + "test_query{somelabel='unicode{}{'}", + + (f"test_query{{somelabel='unicode{{}}{{', " + f"project='{self.rbac.project_id}'}}") + ), ( + "test_query{doesnt_match_regex!~'regex'}", + + (f"test_query{{doesnt_match_regex!~'regex', " + f"project='{self.rbac.project_id}'}}") + ), ( + "delta(cpu_temp_celsius{host='zeus'}[2h]) - " + "sum(http_requests) + " + "sum(http_requests{instance=~'.*'}) + " + "sum(http_requests{or_regex=~'smth1|something2|3'})", + + (f"delta(cpu_temp_celsius{{host='zeus', " + f"project='{self.rbac.project_id}'}}[2h]) - " + f"sum(http_requests" + f"{{project='{self.rbac.project_id}'}}) + " + f"sum(http_requests{{instance=~'.*', " + f"project='{self.rbac.project_id}'}}) + " + f"sum(http_requests{{or_regex=~'smth1|something2|3', " + f"project='{self.rbac.project_id}'}})") + ) + ] + + self.rbac.client.query.list = lambda disable_rbac: ['test_query', + 'cpu_temp_celsius', + 'http_requests'] + + for query, expected in test_cases: + ret = self.rbac.enrich_query(query) + self.assertEqual(ret, expected) + + def test_enrich_query_disable(self): + test_cases = [ + ( + "test_query", + "test_query" + ), ( + "test_query{somelabel='value'}", + "test_query{somelabel='value'}" + ), ( + "test_query{somelabel='value', label2='value2'}", + "test_query{somelabel='value', label2='value2'}" + ), ( + "test_query{somelabel='unicode{}{'}", + "test_query{somelabel='unicode{}{'}" + ), ( + "test_query{doesnt_match_regex!~'regex'}", + "test_query{doesnt_match_regex!~'regex'}", + ), ( + "delta(cpu_temp_celsius{host='zeus'}[2h]) - " + "sum(http_requests) + " + "sum(http_requests{instance=~'.*'}) + " + "sum(http_requests{or_regex=~'smth1|something2|3'})", + + "delta(cpu_temp_celsius{host='zeus'}[2h]) - " + "sum(http_requests) + " + "sum(http_requests{instance=~'.*'}) + " + "sum(http_requests{or_regex=~'smth1|something2|3'})" + ) + ] + + self.rbac.client.query.list = lambda disable_rbac: ['test_query', + 'cpu_temp_celsius', + 'http_requests'] + for query, expected in test_cases: + ret = self.rbac.enrich_query(query, disable_rbac=True) + self.assertEqual(ret, query) + + def test_append_rbac(self): + query = "test_query" + expected = f"{query}{{project='{self.rbac.project_id}'}}" + ret = self.rbac.append_rbac(query) + self.assertEqual(ret, expected) + + def test_append_rbac_disable(self): + query = "test_query" + expected = query + ret = self.rbac.append_rbac(query, disable_rbac=True) + self.assertEqual(ret, expected) diff --git a/observabilityclient/tests/unit/test_utils.py b/observabilityclient/tests/unit/test_utils.py new file mode 100644 index 0000000..be65b11 --- /dev/null +++ b/observabilityclient/tests/unit/test_utils.py @@ -0,0 +1,126 @@ +# Copyright 2023 Red Hat, Inc. +# +# 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. + +import os +from unittest import mock + +import testtools + +from observabilityclient import prometheus_client +from observabilityclient.utils import metric_utils + + +class GetConfigFileTest(testtools.TestCase): + def setUp(self): + super(GetConfigFileTest, self).setUp() + + def test_current_dir(self): + with (mock.patch.object(os.path, 'exists', return_value=True), + mock.patch.object(metric_utils, 'open') as m): + metric_utils.get_config_file() + m.assert_called_with(metric_utils.CONFIG_FILE_NAME, 'r') + + def test_path_order(self): + expected = [mock.call(metric_utils.CONFIG_FILE_NAME, 'r'), + mock.call((f"{os.environ['HOME']}/.config/openstack/" + f"{metric_utils.CONFIG_FILE_NAME}")), + mock.call((f"/etc/openstack/" + f"{metric_utils.CONFIG_FILE_NAME}"))] + with mock.patch.object(os.path, 'exists', return_value=False) as m: + ret = metric_utils.get_config_file() + m.call_args_list == expected + self.assertEqual(ret, None) + + +class GetPrometheusClientTest(testtools.TestCase): + def setUp(self): + super(GetPrometheusClientTest, self).setUp() + config_data = 'host: "somehost"\nport: "1234"' + self.config_file = mock.mock_open(read_data=config_data)("name", 'r') + + def test_get_prometheus_client_from_file(self): + with (mock.patch.object(metric_utils, 'get_config_file', + return_value=self.config_file), + mock.patch.object(prometheus_client.PrometheusAPIClient, + "__init__", return_value=None) as m): + metric_utils.get_prometheus_client() + m.assert_called_with("somehost:1234") + + def test_get_prometheus_client_env_overide(self): + with (mock.patch.dict(os.environ, {'PROMETHEUS_HOST': 'env_overide'}), + mock.patch.object(metric_utils, 'get_config_file', + return_value=self.config_file), + mock.patch.object(prometheus_client.PrometheusAPIClient, + "__init__", return_value=None) as m): + metric_utils.get_prometheus_client() + m.assert_called_with("env_overide:1234") + + def test_get_prometheus_client_no_config_file(self): + patched_env = {'PROMETHEUS_HOST': 'env_overide', + 'PROMETHEUS_PORT': 'env_port'} + with (mock.patch.dict(os.environ, patched_env), + mock.patch.object(prometheus_client.PrometheusAPIClient, + "__init__", return_value=None) as m): + metric_utils.get_prometheus_client() + m.assert_called_with("env_overide:env_port") + + def test_get_prometheus_client_missing_configuration(self): + with (mock.patch.dict(os.environ, {}), + mock.patch.object(prometheus_client.PrometheusAPIClient, + "__init__", return_value=None)): + self.assertRaises(metric_utils.ConfigurationError, + metric_utils.get_prometheus_client) + + +class FormatLabelsTest(testtools.TestCase): + def setUp(self): + super(FormatLabelsTest, self).setUp() + + def test_format_labels_with_normal_labels(self): + input_dict = {"label_key1": "label_value1", + "label_key2": "label_value2"} + expected = "label_key1='label_value1', label_key2='label_value2'" + + ret = metric_utils.format_labels(input_dict) + self.assertEqual(expected, ret) + + def test_format_labels_with_quoted_labels(self): + input_dict = {"label_key1": "'label_value1'", + "label_key2": "'label_value2'"} + expected = "label_key1='label_value1', label_key2='label_value2'" + + ret = metric_utils.format_labels(input_dict) + self.assertEqual(expected, ret) + + +class Metrics2ColsTest(testtools.TestCase): + def setUp(self): + super(Metrics2ColsTest, self).setUp() + + def test_metrics2cols(self): + metric = { + 'value': [ + 1234567, + 5 + ], + 'metric': { + 'label1': 'value1', + 'label2': 'value2', + } + } + input_metrics = [prometheus_client.PrometheusMetric(metric)] + expected = (['label1', 'label2', 'value'], [['value1', 'value2', 5]]) + + ret = metric_utils.metrics2cols(input_metrics) + self.assertEqual(expected, ret) diff --git a/observabilityclient/utils/metric_utils.py b/observabilityclient/utils/metric_utils.py index 632cef4..12cb4bf 100644 --- a/observabilityclient/utils/metric_utils.py +++ b/observabilityclient/utils/metric_utils.py @@ -14,10 +14,12 @@ import logging import os + import yaml from observabilityclient.prometheus_client import PrometheusAPIClient + DEFAULT_CONFIG_LOCATIONS = [os.environ["HOME"] + "/.config/openstack/", "/etc/openstack/"] CONFIG_FILE_NAME = "prometheus.yaml" @@ -30,12 +32,12 @@ class ConfigurationError(Exception): def get_config_file(): if os.path.exists(CONFIG_FILE_NAME): - LOG.debug(f"Using {CONFIG_FILE_NAME} as prometheus configuration") + LOG.debug("Using %s as prometheus configuration", CONFIG_FILE_NAME) return open(CONFIG_FILE_NAME, "r") for path in DEFAULT_CONFIG_LOCATIONS: full_filename = path + CONFIG_FILE_NAME if os.path.exists(full_filename): - LOG.debug(f"Using {full_filename} as prometheus configuration") + LOG.debug("Using %s as prometheus configuration", full_filename) return open(full_filename, "r") return None @@ -68,11 +70,6 @@ def get_client(obj): return obj.app.client_manager.observabilityclient -def list2cols(cols, objs): - return cols, [tuple([o[k] for k in cols]) - for o in objs] - - def format_labels(d: dict) -> str: def replace_doubled_quotes(string): if "''" in string: diff --git a/observabilityclient/v1/base.py b/observabilityclient/v1/base.py index 473d5ef..df1fdd6 100644 --- a/observabilityclient/v1/base.py +++ b/observabilityclient/v1/base.py @@ -44,6 +44,7 @@ class ObservabilityBaseCommand(command.Command): class Manager(object): """Base class for the python api.""" + DEFAULT_HEADERS = { "Accept": "application/json", } diff --git a/observabilityclient/v1/cli.py b/observabilityclient/v1/cli.py index 66fde53..a7640c9 100644 --- a/observabilityclient/v1/cli.py +++ b/observabilityclient/v1/cli.py @@ -12,87 +12,91 @@ # License for the specific language governing permissions and limitations # under the License. -from observabilityclient.utils import metric_utils -from observabilityclient.v1 import base +from cliff import lister + from osc_lib.i18n import _ -from cliff import lister +from observabilityclient.utils import metric_utils +from observabilityclient.v1 import base class List(base.ObservabilityBaseCommand, lister.Lister): - """Query prometheus for list of all metrics""" + """Query prometheus for list of all metrics.""" def take_action(self, parsed_args): client = metric_utils.get_client(self) - metrics = client.query.list(disable_rbac=parsed_args.disable_rbac) + metrics = client.query.list(disable_rbac=parsed_args['disable_rbac']) return ["metric_name"], [[m] for m in metrics] class Show(base.ObservabilityBaseCommand, lister.Lister): - """Query prometheus for the current value of metric""" + """Query prometheus for the current value of metric.""" def get_parser(self, prog_name): parser = super().get_parser(prog_name) parser.add_argument( - 'name', - help=_("Name of the metric to show")) + 'name', + help=_("Name of the metric to show")) return parser def take_action(self, parsed_args): client = metric_utils.get_client(self) - metric = client.query.show(parsed_args.name, - disable_rbac=parsed_args.disable_rbac) - return metric_utils.metrics2cols(metric) + metric = client.query.show(parsed_args['name'], + disable_rbac=parsed_args['disable_rbac']) + ret = metric_utils.metrics2cols(metric) + return ret class Query(base.ObservabilityBaseCommand, lister.Lister): - """Query prometheus with a custom query string""" + """Query prometheus with a custom query string.""" def get_parser(self, prog_name): parser = super().get_parser(prog_name) parser.add_argument( - 'query', - help=_("Custom PromQL query")) + 'query', + help=_("Custom PromQL query")) return parser def take_action(self, parsed_args): client = metric_utils.get_client(self) - metric = client.query.query(parsed_args.query, - disable_rbac=parsed_args.disable_rbac) + metric = client.query.query(parsed_args['query'], + disable_rbac=parsed_args['disable_rbac']) ret = metric_utils.metrics2cols(metric) return ret class Delete(base.ObservabilityBaseCommand): - """Delete data for a selected series and time range""" + """Delete data for a selected series and time range.""" + def get_parser(self, prog_name): parser = super().get_parser(prog_name) parser.add_argument( - 'matches', - action="append", - nargs='+', - help=_("Series selector, that selects the series to delete. " - "Specify multiple selectors delimited by space to " - "delete multiple series.")) + 'matches', + action="append", + nargs='+', + help=_("Series selector, that selects the series to delete. " + "Specify multiple selectors delimited by space to " + "delete multiple series.")) parser.add_argument( - '--start', - help=_("Start timestamp in rfc3339 or unix timestamp. " - "Defaults to minimum possible timestamp.")) + '--start', + help=_("Start timestamp in rfc3339 or unix timestamp. " + "Defaults to minimum possible timestamp.")) parser.add_argument( - '--end', - help=_("End timestamp in rfc3339 or unix timestamp. " - "Defaults to maximum possible timestamp.")) + '--end', + help=_("End timestamp in rfc3339 or unix timestamp. " + "Defaults to maximum possible timestamp.")) return parser def take_action(self, parsed_args): client = metric_utils.get_client(self) - return client.query.delete(parsed_args.matches, - parsed_args.start, - parsed_args.end) + return client.query.delete(parsed_args['matches'], + parsed_args['start'], + parsed_args['end']) class CleanTombstones(base.ObservabilityBaseCommand): - """Remove deleted data from disk and clean up the existing tombstones""" + """Remove deleted data from disk and clean up the existing tombstones.""" + def get_parser(self, prog_name): parser = super().get_parser(prog_name) return parser diff --git a/observabilityclient/v1/client.py b/observabilityclient/v1/client.py index 9c12722..d0ed250 100644 --- a/observabilityclient/v1/client.py +++ b/observabilityclient/v1/client.py @@ -20,7 +20,7 @@ from observabilityclient.v1 import rbac class Client(object): - """Client for the observabilityclient api""" + """Client for the observabilityclient api.""" def __init__(self, session=None, adapter_options=None, session_options=None, disable_rbac=False): diff --git a/observabilityclient/v1/python_api.py b/observabilityclient/v1/python_api.py index 5bba4c8..0eb9ae7 100644 --- a/observabilityclient/v1/python_api.py +++ b/observabilityclient/v1/python_api.py @@ -18,14 +18,14 @@ from observabilityclient.v1 import base class QueryManager(base.Manager): def list(self, disable_rbac=False): - """Lists metric names + """List metric names. :param disable_rbac: Disables rbac injection if set to True :type disable_rbac: boolean """ if disable_rbac or self.client.rbac.disable_rbac: metric_names = self.prom.label_values("__name__") - return metric_names + return sorted(metric_names) else: match = f"{{{format_labels(self.client.rbac.default_labels)}}}" metrics = self.prom.series(match) @@ -35,7 +35,7 @@ class QueryManager(base.Manager): return sorted(unique_metric_names) def show(self, name, disable_rbac=False): - """Shows current values for metrics of a specified name + """Show current values for metrics of a specified name. :param disable_rbac: Disables rbac injection if set to True :type disable_rbac: boolean @@ -46,7 +46,7 @@ class QueryManager(base.Manager): return self.prom.query(last_metric_query) def query(self, query, disable_rbac=False): - """Sends a query to prometheus + """Send a query to prometheus. The query can be any PromQL query. Labels for enforcing rbac will be added to all of the metric name inside the query. @@ -56,18 +56,18 @@ class QueryManager(base.Manager): query("sum(name1) - sum(name2{label1='value'})") will result in a query string like this: "sum(name1{rbac='rbac_value'}) - - sum(name2{label1='value', rbac='rbac_value'})" + sum(name2{label1='value', rbac='rbac_value'})" :param query: Custom query string :type query: str :param disable_rbac: Disables rbac injection if set to True :type disable_rbac: boolean """ - query = self.client.rbac.enrich_query(query, disable_rbac) + query = self.client.rbac.enrich_query(query, disable_rbac=disable_rbac) return self.prom.query(query) def delete(self, matches, start=None, end=None): - """Deletes metrics from Prometheus + """Delete metrics from Prometheus. The metrics aren't deleted immediately. Do a call to clean_tombstones() to speed up the deletion. If start and end isn't specified, then @@ -88,9 +88,9 @@ class QueryManager(base.Manager): return self.prom.delete(matches, start, end) def clean_tombstones(self): - """Instructs prometheus to clean tombstones""" + """Instruct prometheus to clean tombstones.""" return self.prom.clean_tombstones() def snapshot(self): - "Creates a snapshot of the current data" + """Create a snapshot of the current data.""" return self.prom.snapshot() diff --git a/observabilityclient/v1/rbac.py b/observabilityclient/v1/rbac.py index db17c01..96c74e6 100644 --- a/observabilityclient/v1/rbac.py +++ b/observabilityclient/v1/rbac.py @@ -12,10 +12,12 @@ # License for the specific language governing permissions and limitations # under the License. -from keystoneauth1.exceptions.auth_plugins import MissingAuthPlugin -from observabilityclient.utils.metric_utils import format_labels import re +from keystoneauth1.exceptions.auth_plugins import MissingAuthPlugin + +from observabilityclient.utils.metric_utils import format_labels + class ObservabilityRbacError(Exception): pass @@ -29,14 +31,14 @@ class Rbac(): try: self.project_id = self.session.get_project_id() self.default_labels = { - "project": self.project_id - } + "project": self.project_id + } self.rbac_init_successful = True except MissingAuthPlugin: self.project_id = None self.default_labels = { - "project": "no-project" - } + "project": "no-project" + } self.rbac_init_successful = False def _find_label_value_end(self, query, start, quote_char): @@ -48,13 +50,22 @@ class Rbac(): # returns the quote position or -1 return end - def _find_label_pair_end(self, query, start): + def _find_match_operator(self, query, start): eq_sign_pos = query.find('=', start) + tilde_pos = query.find('~', start) + if eq_sign_pos == -1: + return tilde_pos + if tilde_pos == -1: + return eq_sign_pos + return min(eq_sign_pos, tilde_pos) + + def _find_label_pair_end(self, query, start): + match_operator_pos = self._find_match_operator(query, start) quote_char = "'" - quote_start_pos = query.find(quote_char, eq_sign_pos) + quote_start_pos = query.find(quote_char, match_operator_pos) if quote_start_pos == -1: quote_char = '"' - quote_start_pos = query.find(quote_char, eq_sign_pos) + quote_start_pos = query.find(quote_char, match_operator_pos) end = self._find_label_value_end(query, quote_start_pos, quote_char) # returns the pair end or -1 return end @@ -64,18 +75,19 @@ class Rbac(): while nearest_curly_brace_pos != -1: pair_end = self._find_label_pair_end(query, start) nearest_curly_brace_pos = query.find("}", pair_end) - nearest_eq_sign_pos = query.find("=", pair_end) - if (nearest_curly_brace_pos < nearest_eq_sign_pos or - nearest_eq_sign_pos == -1): - # If we have "}" before the nearest "=", + nearest_match_operator_pos = self._find_match_operator(query, + pair_end) + if (nearest_curly_brace_pos < nearest_match_operator_pos or + nearest_match_operator_pos == -1): + # If we have "}" before the nearest "=" or "~", # then we must be at the end of the label section - # and the "=" is a part of the next section. + # and the "=" or "~" is a part of the next section. return nearest_curly_brace_pos start = pair_end return -1 def enrich_query(self, query, disable_rbac=False): - """Used to add rbac labels to queries + """Add rbac labels to queries. :param query: The query to enrich :type query: str @@ -121,7 +133,7 @@ class Rbac(): return query def append_rbac(self, query, disable_rbac=False): - """Used to append rbac labels to queries + """Append rbac labels to queries. It's a simplified and faster version of enrich_query(). This just appends the labels at the end of the query string. For proper handling diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ac645ff --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +osc-lib>=1.0.1 # Apache-2.0 +keystoneauth1>=1.0.0 +cliff!=1.16.0,>=1.14.0 # Apache-2.0 diff --git a/setup.cfg b/setup.cfg index 7a3c674..49f0499 100644 --- a/setup.cfg +++ b/setup.cfg @@ -34,6 +34,18 @@ classifier = packages = observabilityclient +[options.extras_require] +test = + coverage>=3.6 + oslotest>=1.10.0 # Apache-2.0 + reno>=1.6.2 # Apache2 + tempest>=10 + stestr>=2.0.0 # Apache-2.0 + testtools>=1.4.0 + pifpaf[gnocchi]>=0.23 + SQLAlchemy<=1.4.41 + oslo.db<=12.3.1 + [entry_points] openstack.cli.extension = observabilityclient = observabilityclient.plugin diff --git a/tools/install_deps.sh b/tools/install_deps.sh new file mode 100755 index 0000000..a42a14c --- /dev/null +++ b/tools/install_deps.sh @@ -0,0 +1,25 @@ +#!/bin/bash -ex + +sudo apt-get update -y && sudo apt-get install -qy gnupg software-properties-common +sudo add-apt-repository -y ppa:deadsnakes/ppa +sudo apt-get update -y && sudo apt-get install -qy \ + locales \ + git \ + wget \ + curl \ + python3 \ + python3-dev \ + python3-pip \ + python3.9 \ + python3.9-dev \ + python3.9-distutils \ + python3.11 \ + python3.11-dev + +sudo rm -rf /var/lib/apt/lists/* + +export LANG=en_US.UTF-8 +sudo update-locale +sudo locale-gen $LANG + +sudo python3 -m pip install -U pip tox virtualenv diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..2587f20 --- /dev/null +++ b/tox.ini @@ -0,0 +1,47 @@ +[tox] +minversion = 4.2.5 +envlist = py38,py39,py311,pep8 +ignore_basepython_conflict = True + +[testenv] +basepython = python3 +usedevelop = True +setenv = + VIRTUAL_ENV={envdir} + OBSERVABILITY_CLIENT_EXEC_DIR={envdir}/bin +passenv = + PROMETHEUS_* + OBSERVABILITY_* +deps = .[test] + pytest +commands = pytest {posargs:observabilityclient/tests} + +[testenv:pep8] +basepython = python3 +deps = flake8 + flake8-blind-except + flake8-builtins + flake8-docstrings + flake8-logging-format + hacking<3.1.0,>=3.0 +commands = flake8 + +[testenv:venv] +deps = .[test,doc] +commands = {posargs} + +[testenv:cover] +deps = {[testenv]deps} + pytest-cov +commands = observabilityclient {posargs:observabilityclient/tests} + +[flake8] +show-source = True +ignore = D100,D101,D102,D103,D104,D105,D106,D107,A002,A003,W504,W503 +exclude=.git,.tox,dist,doc,*egg,build +enable-extensions=G +application-import-names = observabilityclient + +[pytest] +addopts = --verbose +norecursedirs = .tox