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:
parent
037437e995
commit
53b335aaca
23
.github/workflows/unit_tests.yml
vendored
Normal file
23
.github/workflows/unit_tests.yml
vendored
Normal 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 }}
|
@ -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
|
||||
|
@ -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"]
|
||||
|
0
observabilityclient/tests/unit/__init__.py
Normal file
0
observabilityclient/tests/unit/__init__.py
Normal file
142
observabilityclient/tests/unit/test_cli.py
Normal file
142
observabilityclient/tests/unit/test_cli.py
Normal 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]]))
|
515
observabilityclient/tests/unit/test_prometheus_client.py
Normal file
515
observabilityclient/tests/unit/test_prometheus_client.py
Normal 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)
|
126
observabilityclient/tests/unit/test_python_api.py
Normal file
126
observabilityclient/tests/unit/test_python_api.py
Normal 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()
|
146
observabilityclient/tests/unit/test_rbac.py
Normal file
146
observabilityclient/tests/unit/test_rbac.py
Normal 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)
|
126
observabilityclient/tests/unit/test_utils.py
Normal file
126
observabilityclient/tests/unit/test_utils.py
Normal 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)
|
@ -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:
|
||||
|
@ -44,6 +44,7 @@ class ObservabilityBaseCommand(command.Command):
|
||||
|
||||
class Manager(object):
|
||||
"""Base class for the python api."""
|
||||
|
||||
DEFAULT_HEADERS = {
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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()
|
||||
|
@ -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
3
requirements.txt
Normal 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
|
12
setup.cfg
12
setup.cfg
@ -34,6 +34,18 @@ classifier =
|
||||
packages =
|
||||
observabilityclient
|
||||
|
||||
[options.extras_require]
|
||||
test =
|
||||
coverage>=3.6
|
||||
oslotest>=1.10.0 # Apache-2.0
|
||||
reno>=1.6.2 # Apache2
|
||||
tempest>=10
|
||||
stestr>=2.0.0 # Apache-2.0
|
||||
testtools>=1.4.0
|
||||
pifpaf[gnocchi]>=0.23
|
||||
SQLAlchemy<=1.4.41
|
||||
oslo.db<=12.3.1
|
||||
|
||||
[entry_points]
|
||||
openstack.cli.extension =
|
||||
observabilityclient = observabilityclient.plugin
|
||||
|
25
tools/install_deps.sh
Executable file
25
tools/install_deps.sh
Executable 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
47
tox.ini
Normal 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
|
Loading…
Reference in New Issue
Block a user