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 # License for the specific language governing permissions and limitations
# under the License. # under the License.
"""OpenStackClient Plugin interface""" """OpenStackClient Plugin interface."""
from osc_lib import utils from osc_lib import utils
@ -26,7 +26,7 @@ API_VERSIONS = {
def make_client(instance): 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 Called to instantiate the requested client version. instance has
any available auth info that may be required to prepare the client. 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): def build_option_parser(parser):
"""Hook to add global options """Add global options.
Called from openstackclient.shell.OpenStackShell.__init__() Called from openstackclient.shell.OpenStackShell.__init__()
after the builtin parser has been initialized. This is after the builtin parser has been initialized. This is

View File

@ -13,6 +13,7 @@
# under the License. # under the License.
import logging import logging
import requests import requests
@ -95,13 +96,12 @@ class PrometheusAPIClient:
return decoded return decoded
def query(self, query): def query(self, query):
"""Sends custom queries to Prometheus """Send custom queries to Prometheus.
:param query: the query to send :param query: the query to send
:type query: str :type query: str
""" """
LOG.debug("Querying prometheus with query: %s", query)
LOG.debug(f"Querying prometheus with query: {query}")
decoded = self._get("query", dict(query=query)) decoded = self._get("query", dict(query=query))
if decoded['data']['resultType'] == 'vector': if decoded['data']['resultType'] == 'vector':
@ -111,38 +111,35 @@ class PrometheusAPIClient:
return result return result
def series(self, matches): 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 :param matches: List of matches to send as parameters
:type matches: [str] :type matches: [str]
""" """
LOG.debug("Querying prometheus for series with matches: %s", matches)
LOG.debug(f"Querying prometheus for series with matches: {matches}")
decoded = self._get("series", {"match[]": matches}) decoded = self._get("series", {"match[]": matches})
return decoded['data'] return decoded['data']
def labels(self): 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 There isn't a way to tell prometheus to restrict
which labels to return. It's not possible to enforce which labels to return. It's not possible to enforce
rbac with this for example. rbac with this for example.
""" """
LOG.debug("Querying prometheus for labels") LOG.debug("Querying prometheus for labels")
decoded = self._get("labels") decoded = self._get("labels")
return decoded['data'] return decoded['data']
def label_values(self, label): 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 :param label: Name of label for which to return values
:type label: str :type label: str
""" """
LOG.debug("Querying prometheus for the values of label: %s", label)
LOG.debug(f"Querying prometheus for the values of label: {label}")
decoded = self._get(f"label/{label}/values") decoded = self._get(f"label/{label}/values")
return decoded['data'] return decoded['data']
@ -152,7 +149,7 @@ class PrometheusAPIClient:
# --------- # ---------
def delete(self, matches, start=None, end=None): 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 :param matches: List of matches, that specify which metrics to delete
:type matches [str] :type matches [str]
@ -168,8 +165,7 @@ class PrometheusAPIClient:
# way to know if anything got actually deleted. # way to know if anything got actually deleted.
# It does however return 500 code and error msg # It does however return 500 code and error msg
# if the admin APIs are disabled. # if the admin APIs are disabled.
LOG.debug("Deleting metrics from prometheus matching: %s", matches)
LOG.debug(f"Deleting metrics from prometheus matching: {matches}")
try: try:
self._post("admin/tsdb/delete_series", {"match[]": matches, self._post("admin/tsdb/delete_series", {"match[]": matches,
"start": start, "start": start,
@ -181,8 +177,7 @@ class PrometheusAPIClient:
raise exc raise exc
def clean_tombstones(self): def clean_tombstones(self):
"""Asks prometheus to clean tombstones""" """Ask prometheus to clean tombstones."""
LOG.debug("Cleaning tombstones from prometheus") LOG.debug("Cleaning tombstones from prometheus")
try: try:
self._post("admin/tsdb/clean_tombstones") self._post("admin/tsdb/clean_tombstones")
@ -193,8 +188,7 @@ class PrometheusAPIClient:
raise exc raise exc
def snapshot(self): 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") LOG.debug("Taking prometheus data snapshot")
ret = self._post("admin/tsdb/snapshot") ret = self._post("admin/tsdb/snapshot")
return ret["data"]["name"] 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 logging
import os import os
import yaml import yaml
from observabilityclient.prometheus_client import PrometheusAPIClient from observabilityclient.prometheus_client import PrometheusAPIClient
DEFAULT_CONFIG_LOCATIONS = [os.environ["HOME"] + "/.config/openstack/", DEFAULT_CONFIG_LOCATIONS = [os.environ["HOME"] + "/.config/openstack/",
"/etc/openstack/"] "/etc/openstack/"]
CONFIG_FILE_NAME = "prometheus.yaml" CONFIG_FILE_NAME = "prometheus.yaml"
@ -30,12 +32,12 @@ class ConfigurationError(Exception):
def get_config_file(): def get_config_file():
if os.path.exists(CONFIG_FILE_NAME): 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") return open(CONFIG_FILE_NAME, "r")
for path in DEFAULT_CONFIG_LOCATIONS: for path in DEFAULT_CONFIG_LOCATIONS:
full_filename = path + CONFIG_FILE_NAME full_filename = path + CONFIG_FILE_NAME
if os.path.exists(full_filename): 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 open(full_filename, "r")
return None return None
@ -68,11 +70,6 @@ def get_client(obj):
return obj.app.client_manager.observabilityclient 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 format_labels(d: dict) -> str:
def replace_doubled_quotes(string): def replace_doubled_quotes(string):
if "''" in string: if "''" in string:

View File

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

View File

@ -12,24 +12,25 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
from observabilityclient.utils import metric_utils from cliff import lister
from observabilityclient.v1 import base
from osc_lib.i18n import _ 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): 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): def take_action(self, parsed_args):
client = metric_utils.get_client(self) 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] return ["metric_name"], [[m] for m in metrics]
class Show(base.ObservabilityBaseCommand, lister.Lister): 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): def get_parser(self, prog_name):
parser = super().get_parser(prog_name) parser = super().get_parser(prog_name)
@ -40,13 +41,14 @@ class Show(base.ObservabilityBaseCommand, lister.Lister):
def take_action(self, parsed_args): def take_action(self, parsed_args):
client = metric_utils.get_client(self) client = metric_utils.get_client(self)
metric = client.query.show(parsed_args.name, metric = client.query.show(parsed_args['name'],
disable_rbac=parsed_args.disable_rbac) disable_rbac=parsed_args['disable_rbac'])
return metric_utils.metrics2cols(metric) ret = metric_utils.metrics2cols(metric)
return ret
class Query(base.ObservabilityBaseCommand, lister.Lister): 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): def get_parser(self, prog_name):
parser = super().get_parser(prog_name) parser = super().get_parser(prog_name)
@ -57,14 +59,15 @@ class Query(base.ObservabilityBaseCommand, lister.Lister):
def take_action(self, parsed_args): def take_action(self, parsed_args):
client = metric_utils.get_client(self) client = metric_utils.get_client(self)
metric = client.query.query(parsed_args.query, metric = client.query.query(parsed_args['query'],
disable_rbac=parsed_args.disable_rbac) disable_rbac=parsed_args['disable_rbac'])
ret = metric_utils.metrics2cols(metric) ret = metric_utils.metrics2cols(metric)
return ret return ret
class Delete(base.ObservabilityBaseCommand): 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): def get_parser(self, prog_name):
parser = super().get_parser(prog_name) parser = super().get_parser(prog_name)
parser.add_argument( parser.add_argument(
@ -86,13 +89,14 @@ class Delete(base.ObservabilityBaseCommand):
def take_action(self, parsed_args): def take_action(self, parsed_args):
client = metric_utils.get_client(self) client = metric_utils.get_client(self)
return client.query.delete(parsed_args.matches, return client.query.delete(parsed_args['matches'],
parsed_args.start, parsed_args['start'],
parsed_args.end) parsed_args['end'])
class CleanTombstones(base.ObservabilityBaseCommand): 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): def get_parser(self, prog_name):
parser = super().get_parser(prog_name) parser = super().get_parser(prog_name)
return parser return parser

View File

@ -20,7 +20,7 @@ from observabilityclient.v1 import rbac
class Client(object): class Client(object):
"""Client for the observabilityclient api""" """Client for the observabilityclient api."""
def __init__(self, session=None, adapter_options=None, def __init__(self, session=None, adapter_options=None,
session_options=None, disable_rbac=False): session_options=None, disable_rbac=False):

View File

@ -18,14 +18,14 @@ from observabilityclient.v1 import base
class QueryManager(base.Manager): class QueryManager(base.Manager):
def list(self, disable_rbac=False): def list(self, disable_rbac=False):
"""Lists metric names """List metric names.
:param disable_rbac: Disables rbac injection if set to True :param disable_rbac: Disables rbac injection if set to True
:type disable_rbac: boolean :type disable_rbac: boolean
""" """
if disable_rbac or self.client.rbac.disable_rbac: if disable_rbac or self.client.rbac.disable_rbac:
metric_names = self.prom.label_values("__name__") metric_names = self.prom.label_values("__name__")
return metric_names return sorted(metric_names)
else: else:
match = f"{{{format_labels(self.client.rbac.default_labels)}}}" match = f"{{{format_labels(self.client.rbac.default_labels)}}}"
metrics = self.prom.series(match) metrics = self.prom.series(match)
@ -35,7 +35,7 @@ class QueryManager(base.Manager):
return sorted(unique_metric_names) return sorted(unique_metric_names)
def show(self, name, disable_rbac=False): 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 :param disable_rbac: Disables rbac injection if set to True
:type disable_rbac: boolean :type disable_rbac: boolean
@ -46,7 +46,7 @@ class QueryManager(base.Manager):
return self.prom.query(last_metric_query) return self.prom.query(last_metric_query)
def query(self, query, disable_rbac=False): 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 The query can be any PromQL query. Labels for enforcing
rbac will be added to all of the metric name inside the query. 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 :param disable_rbac: Disables rbac injection if set to True
:type disable_rbac: boolean :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) return self.prom.query(query)
def delete(self, matches, start=None, end=None): 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() 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 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) return self.prom.delete(matches, start, end)
def clean_tombstones(self): def clean_tombstones(self):
"""Instructs prometheus to clean tombstones""" """Instruct prometheus to clean tombstones."""
return self.prom.clean_tombstones() return self.prom.clean_tombstones()
def snapshot(self): def snapshot(self):
"Creates a snapshot of the current data" """Create a snapshot of the current data."""
return self.prom.snapshot() return self.prom.snapshot()

View File

@ -12,10 +12,12 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
from keystoneauth1.exceptions.auth_plugins import MissingAuthPlugin
from observabilityclient.utils.metric_utils import format_labels
import re import re
from keystoneauth1.exceptions.auth_plugins import MissingAuthPlugin
from observabilityclient.utils.metric_utils import format_labels
class ObservabilityRbacError(Exception): class ObservabilityRbacError(Exception):
pass pass
@ -48,13 +50,22 @@ class Rbac():
# returns the quote position or -1 # returns the quote position or -1
return end return end
def _find_label_pair_end(self, query, start): def _find_match_operator(self, query, start):
eq_sign_pos = query.find('=', 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_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: if quote_start_pos == -1:
quote_char = '"' 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) end = self._find_label_value_end(query, quote_start_pos, quote_char)
# returns the pair end or -1 # returns the pair end or -1
return end return end
@ -64,18 +75,19 @@ class Rbac():
while nearest_curly_brace_pos != -1: while nearest_curly_brace_pos != -1:
pair_end = self._find_label_pair_end(query, start) pair_end = self._find_label_pair_end(query, start)
nearest_curly_brace_pos = query.find("}", pair_end) nearest_curly_brace_pos = query.find("}", pair_end)
nearest_eq_sign_pos = query.find("=", pair_end) nearest_match_operator_pos = self._find_match_operator(query,
if (nearest_curly_brace_pos < nearest_eq_sign_pos or pair_end)
nearest_eq_sign_pos == -1): if (nearest_curly_brace_pos < nearest_match_operator_pos or
# If we have "}" before the nearest "=", nearest_match_operator_pos == -1):
# If we have "}" before the nearest "=" or "~",
# then we must be at the end of the label section # 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 return nearest_curly_brace_pos
start = pair_end start = pair_end
return -1 return -1
def enrich_query(self, query, disable_rbac=False): 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 :param query: The query to enrich
:type query: str :type query: str
@ -121,7 +133,7 @@ class Rbac():
return query return query
def append_rbac(self, query, disable_rbac=False): 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 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 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 = packages =
observabilityclient 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] [entry_points]
openstack.cli.extension = openstack.cli.extension =
observabilityclient = observabilityclient.plugin 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