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
This commit is contained in:
Jaromír Wysoglad 2023-09-05 14:54:33 +02:00 committed by GitHub
parent 037437e995
commit 53b335aaca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1260 additions and 87 deletions

23
.github/workflows/unit_tests.yml vendored Normal file
View File

@ -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 }}

View File

@ -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

View File

@ -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"]

View File

@ -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]]))

View File

@ -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)

View File

@ -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()

View File

@ -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)

View File

@ -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)

View File

@ -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:

View File

@ -44,6 +44,7 @@ class ObservabilityBaseCommand(command.Command):
class Manager(object):
"""Base class for the python api."""
DEFAULT_HEADERS = {
"Accept": "application/json",
}

View File

@ -12,24 +12,25 @@
# 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)
@ -40,13 +41,14 @@ class Show(base.ObservabilityBaseCommand, lister.Lister):
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)
@ -57,14 +59,15 @@ class Query(base.ObservabilityBaseCommand, lister.Lister):
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(
@ -86,13 +89,14 @@ class Delete(base.ObservabilityBaseCommand):
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

View File

@ -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):

View File

@ -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.
@ -63,11 +63,11 @@ class QueryManager(base.Manager):
: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()

View File

@ -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
@ -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

3
requirements.txt Normal file
View File

@ -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

View File

@ -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

25
tools/install_deps.sh Executable file
View File

@ -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

47
tox.ini Normal file
View File

@ -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