Prometheus interaction (#7)
* Remove old observability client
* Add initial functionality for prometheus querying
* Fix a copy-paste error in get_client()
* Add additional functionality.
This commit adds:
    - commands:
        delete
        clear-tombstones
        snapshot
    - Better rbac injection as well as a possibility
      to disable rbac.
    - Configuration of prometheus_client through
      env variables and /etc/openstack/prometheus.yaml
* Make README up to date
* Implement Martin's PR comments
* Implement better support for label values in rbac
* PEP8
			
			
This commit is contained in:
		
							
								
								
									
										74
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										74
									
								
								README.md
									
									
									
									
									
								
							@@ -1,8 +1,7 @@
 | 
			
		||||
# python-observabilityclient
 | 
			
		||||
 | 
			
		||||
observabilityclient is an OpenStackClient (OSC) plugin implementation that
 | 
			
		||||
implements commands for management of OpenStack observability components such
 | 
			
		||||
as Prometheus, collectd and Ceilometer.
 | 
			
		||||
implements commands for management of Prometheus.
 | 
			
		||||
 | 
			
		||||
## Development
 | 
			
		||||
 | 
			
		||||
@@ -17,58 +16,37 @@ su - stack
 | 
			
		||||
git clone https://github.com/infrawatch/python-observabilityclient
 | 
			
		||||
cd python-observabilityclient
 | 
			
		||||
sudo python setup.py install --prefix=/usr
 | 
			
		||||
 | 
			
		||||
# clone and install observability playbooks and roles
 | 
			
		||||
git clone https://github.com/infrawatch/osp-observability-ansible
 | 
			
		||||
sudo mkdir /usr/share/osp-observability
 | 
			
		||||
sudo ln -s `pwd`/osp-observability-ansible/playbooks /usr/share/osp-observability/playbooks
 | 
			
		||||
sudo ln -s `pwd`/osp-observability-ansible/roles/spawn_container /usr/share/ansible/roles/spawn_container
 | 
			
		||||
sudo ln -s `pwd`/osp-observability-ansible/roles/osp_observability /usr/share/ansible/roles/osp_observability
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Enable collectd write_prometheus
 | 
			
		||||
Create a THT environment file to enable the write_prometheus plugin for the collectd service. Then redeploy your overcloud and include this new file:
 | 
			
		||||
## Usage
 | 
			
		||||
 | 
			
		||||
Use `openstack metric query somequery` to query for metrics in prometheus.
 | 
			
		||||
 | 
			
		||||
To use the python api do the following:
 | 
			
		||||
```
 | 
			
		||||
mkdir -p ~/templates/observability
 | 
			
		||||
cat <EOF >> templates/observability/collectd-write-prometheus.yaml
 | 
			
		||||
resource_registry:
 | 
			
		||||
  OS::TripleO::Services::Collectd: /usr/share/openstack-tripleo-heat-templates/deployment/metrics/collectd-container-puppet.yaml
 | 
			
		||||
from observabilityclient import client
 | 
			
		||||
 | 
			
		||||
# TEST
 | 
			
		||||
# parameter_merge_strategies:
 | 
			
		||||
#   CollectdExtraPlugins: merge
 | 
			
		||||
 | 
			
		||||
parameter_defaults:
 | 
			
		||||
  CollectdExtraPlugins:
 | 
			
		||||
    - write_prometheus
 | 
			
		||||
EOF
 | 
			
		||||
c = client.Client(
 | 
			
		||||
            '1', keystone_client.get_session(conf),
 | 
			
		||||
            adapter_options={
 | 
			
		||||
                'interface': conf.service_credentials.interface,
 | 
			
		||||
                'region_name': conf.service_credentials.region_name})
 | 
			
		||||
c.query.query("somequery")
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Discover endpoints
 | 
			
		||||
After deployment of your cloud you can discover endpoints available for scraping:
 | 
			
		||||
## List of commands
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
source stackrc
 | 
			
		||||
openstack observability discover --stack-name=standalone
 | 
			
		||||
```
 | 
			
		||||
openstack metric list - lists all metrics
 | 
			
		||||
openstack metric show - shows current values of a metric
 | 
			
		||||
openstack metric query - queries prometheus and outputs the result
 | 
			
		||||
openstack metric delete - deletes some metrics
 | 
			
		||||
openstack metric snapshot - takes a snapshot of the current data
 | 
			
		||||
openstack metric clean-tombstones - cleans the tsdb tombstones
 | 
			
		||||
 | 
			
		||||
### Deploy prometheus:
 | 
			
		||||
Create a config file and run the setup command
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
$ cat test_params.yaml
 | 
			
		||||
prometheus_remote_write:
 | 
			
		||||
  stf:
 | 
			
		||||
    url: https://default-prometheus-proxy-service-telemetry.apps.FAKE.ocp.cluster/api/v1/write
 | 
			
		||||
    basic_user: internal
 | 
			
		||||
    basic_pass: Pl4iNt3xTp4a55
 | 
			
		||||
    ca_cert: |
 | 
			
		||||
      -----BEGIN CERTIFICATE-----
 | 
			
		||||
      ABCDEFGHIJKLMNOPQRSTUVWXYZ
 | 
			
		||||
      -----END CERTIFICATE-----
 | 
			
		||||
  not-stf:
 | 
			
		||||
    url: http://prometheus-rw.example.com/api/v1/write
 | 
			
		||||
 | 
			
		||||
$ openstack observability setup prometheus_agent --config ./test_params.yaml
 | 
			
		||||
```
 | 
			
		||||
## List of functions provided by the python library
 | 
			
		||||
c.query.list - lists all metrics
 | 
			
		||||
c.query.show - shows current values of a metric
 | 
			
		||||
c.query.query - queries prometheus and outputs the result
 | 
			
		||||
c.query.delete - deletes some metrics
 | 
			
		||||
c.query.snapshot - takes a snapshot of the current data
 | 
			
		||||
c.query.clean-tombstones - cleans the tsdb tombstones
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										22
									
								
								observabilityclient/client.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								observabilityclient/client.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,22 @@
 | 
			
		||||
#   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 sys
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def Client(version, *args, **kwargs):
 | 
			
		||||
    module = 'observabilityclient.v%s.client' % version
 | 
			
		||||
    __import__(module)
 | 
			
		||||
    client_class = getattr(sys.modules[module], 'Client')
 | 
			
		||||
    return client_class(*args, **kwargs)
 | 
			
		||||
@@ -1,3 +1,16 @@
 | 
			
		||||
#   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.
 | 
			
		||||
 | 
			
		||||
"""OpenStackClient Plugin interface"""
 | 
			
		||||
 | 
			
		||||
@@ -8,7 +21,7 @@ DEFAULT_API_VERSION = '1'
 | 
			
		||||
API_NAME = 'observabilityclient'
 | 
			
		||||
API_VERSION_OPTION = 'os_observabilityclient_api_version'
 | 
			
		||||
API_VERSIONS = {
 | 
			
		||||
    '1': 'observabilityclient.plugin',
 | 
			
		||||
    '1': 'observabilityclient.v1.client.Client',
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -20,12 +33,16 @@ def make_client(instance):
 | 
			
		||||
 | 
			
		||||
    :param ClientManager instance: The ClientManager that owns the new client
 | 
			
		||||
    """
 | 
			
		||||
    plugin_client = utils.get_client_class(
 | 
			
		||||
    observability_client = utils.get_client_class(
 | 
			
		||||
        API_NAME,
 | 
			
		||||
        instance._api_version[API_NAME],
 | 
			
		||||
        API_VERSIONS)
 | 
			
		||||
 | 
			
		||||
    client = plugin_client()
 | 
			
		||||
    client = observability_client(session=instance.session,
 | 
			
		||||
                                  adapter_options={
 | 
			
		||||
                                      'interface': instance.interface,
 | 
			
		||||
                                      'region_name': instance.region_name
 | 
			
		||||
                                  })
 | 
			
		||||
    return client
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										200
									
								
								observabilityclient/prometheus_client.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										200
									
								
								observabilityclient/prometheus_client.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,200 @@
 | 
			
		||||
#   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 logging
 | 
			
		||||
import requests
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
LOG = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PrometheusAPIClientError(Exception):
 | 
			
		||||
    def __init__(self, response):
 | 
			
		||||
        self.resp = response
 | 
			
		||||
 | 
			
		||||
    def __str__(self) -> str:
 | 
			
		||||
        if self.resp.status_code != requests.codes.ok:
 | 
			
		||||
            if self.resp.status_code != 204:
 | 
			
		||||
                decoded = self.resp.json()
 | 
			
		||||
                if 'error' in decoded:
 | 
			
		||||
                    return f'[{self.resp.status_code}] {decoded["error"]}'
 | 
			
		||||
            return f'[{self.resp.status_code}] {self.resp.reason}'
 | 
			
		||||
        else:
 | 
			
		||||
            decoded = self.resp.json()
 | 
			
		||||
            return f'[{decoded.status}]'
 | 
			
		||||
 | 
			
		||||
    def __repr__(self) -> str:
 | 
			
		||||
        if self.resp.status_code != requests.codes.ok:
 | 
			
		||||
            if self.resp.status_code != 204:
 | 
			
		||||
                decoded = self.resp.json()
 | 
			
		||||
                if 'error' in decoded:
 | 
			
		||||
                    return f'[{self.resp.status_code}] {decoded["error"]}'
 | 
			
		||||
            return f'[{self.resp.status_code}] {self.resp.reason}'
 | 
			
		||||
        else:
 | 
			
		||||
            decoded = self.resp.json()
 | 
			
		||||
            return f'[{decoded.status}]'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PrometheusMetric:
 | 
			
		||||
    def __init__(self, input):
 | 
			
		||||
        self.timestamp = input['value'][0]
 | 
			
		||||
        self.labels = input['metric']
 | 
			
		||||
        self.value = input['value'][1]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PrometheusAPIClient:
 | 
			
		||||
    def __init__(self, host):
 | 
			
		||||
        self._host = host
 | 
			
		||||
        self._session = requests.Session()
 | 
			
		||||
        self._session.verify = False
 | 
			
		||||
 | 
			
		||||
    def set_ca_cert(self, ca_cert):
 | 
			
		||||
        self._session.verify = ca_cert
 | 
			
		||||
 | 
			
		||||
    def set_client_cert(self, client_cert, client_key):
 | 
			
		||||
        self._session.cert = client_cert
 | 
			
		||||
        self._session.key = client_key
 | 
			
		||||
 | 
			
		||||
    def set_basic_auth(self, auth_user, auth_password):
 | 
			
		||||
        self._session.auth = (auth_user, auth_password)
 | 
			
		||||
 | 
			
		||||
    def _get(self, endpoint, params=None):
 | 
			
		||||
        url = (f"{'https' if self._session.verify else 'http'}://"
 | 
			
		||||
               f"{self._host}/api/v1/{endpoint}")
 | 
			
		||||
        resp = self._session.get(url, params=params,
 | 
			
		||||
                                 headers={'Accept': 'application/json'})
 | 
			
		||||
        if resp.status_code != requests.codes.ok:
 | 
			
		||||
            raise PrometheusAPIClientError(resp)
 | 
			
		||||
        decoded = resp.json()
 | 
			
		||||
        if decoded['status'] != 'success':
 | 
			
		||||
            raise PrometheusAPIClientError(resp)
 | 
			
		||||
 | 
			
		||||
        return decoded
 | 
			
		||||
 | 
			
		||||
    def _post(self, endpoint, params=None):
 | 
			
		||||
        url = (f"{'https' if self._session.verify else 'http'}://"
 | 
			
		||||
               f"{self._host}/api/v1/{endpoint}")
 | 
			
		||||
        resp = self._session.post(url, params=params,
 | 
			
		||||
                                  headers={'Accept': 'application/json'})
 | 
			
		||||
        if resp.status_code != requests.codes.ok:
 | 
			
		||||
            raise PrometheusAPIClientError(resp)
 | 
			
		||||
        decoded = resp.json()
 | 
			
		||||
        if 'status' in decoded and decoded['status'] != 'success':
 | 
			
		||||
            raise PrometheusAPIClientError(resp)
 | 
			
		||||
        return decoded
 | 
			
		||||
 | 
			
		||||
    def query(self, query):
 | 
			
		||||
        """Sends custom queries to Prometheus
 | 
			
		||||
 | 
			
		||||
        :param query: the query to send
 | 
			
		||||
        :type query: str
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        LOG.debug(f"Querying prometheus with query: {query}")
 | 
			
		||||
        decoded = self._get("query", dict(query=query))
 | 
			
		||||
 | 
			
		||||
        if decoded['data']['resultType'] == 'vector':
 | 
			
		||||
            result = [PrometheusMetric(i) for i in decoded['data']['result']]
 | 
			
		||||
        else:
 | 
			
		||||
            result = [PrometheusMetric(decoded)]
 | 
			
		||||
        return result
 | 
			
		||||
 | 
			
		||||
    def series(self, matches):
 | 
			
		||||
        """Queries 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}")
 | 
			
		||||
        decoded = self._get("series", {"match[]": matches})
 | 
			
		||||
 | 
			
		||||
        return decoded['data']
 | 
			
		||||
 | 
			
		||||
    def labels(self):
 | 
			
		||||
        """Queries 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.
 | 
			
		||||
 | 
			
		||||
        :param label: Name of label for which to return values
 | 
			
		||||
        :type label: str
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        LOG.debug(f"Querying prometheus for the values of label: {label}")
 | 
			
		||||
        decoded = self._get(f"label/{label}/values")
 | 
			
		||||
 | 
			
		||||
        return decoded['data']
 | 
			
		||||
 | 
			
		||||
    # ---------
 | 
			
		||||
    # admin api
 | 
			
		||||
    # ---------
 | 
			
		||||
 | 
			
		||||
    def delete(self, matches, start=None, end=None):
 | 
			
		||||
        """Deletes some metrics from prometheus
 | 
			
		||||
 | 
			
		||||
        :param matches: List of matches, that specify which metrics to delete
 | 
			
		||||
        :type matches [str]
 | 
			
		||||
        :param start: Timestamp from which to start deleting.
 | 
			
		||||
                      None for as early as possible.
 | 
			
		||||
        :type start: timestamp
 | 
			
		||||
        :param end: Timestamp until which to delete.
 | 
			
		||||
                    None for as late as possible.
 | 
			
		||||
        :type end: timestamp
 | 
			
		||||
        """
 | 
			
		||||
        # NOTE Prometheus doesn't seem to return anything except
 | 
			
		||||
        #      of 204 status code. There doesn't seem to be a
 | 
			
		||||
        #      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}")
 | 
			
		||||
        try:
 | 
			
		||||
            self._post("admin/tsdb/delete_series", {"match[]": matches,
 | 
			
		||||
                                                    "start": start,
 | 
			
		||||
                                                    "end": end})
 | 
			
		||||
        except PrometheusAPIClientError as exc:
 | 
			
		||||
            # The 204 is allowed here. 204 is "No Content",
 | 
			
		||||
            # which is expected on a successful call
 | 
			
		||||
            if exc.resp.status_code != 204:
 | 
			
		||||
                raise exc
 | 
			
		||||
 | 
			
		||||
    def clean_tombstones(self):
 | 
			
		||||
        """Asks prometheus to clean tombstones"""
 | 
			
		||||
 | 
			
		||||
        LOG.debug("Cleaning tombstones from prometheus")
 | 
			
		||||
        try:
 | 
			
		||||
            self._post("admin/tsdb/clean_tombstones")
 | 
			
		||||
        except PrometheusAPIClientError as exc:
 | 
			
		||||
            # The 204 is allowed here. 204 is "No Content",
 | 
			
		||||
            # which is expected on a successful call
 | 
			
		||||
            if exc.resp.status_code != 204:
 | 
			
		||||
                raise exc
 | 
			
		||||
 | 
			
		||||
    def snapshot(self):
 | 
			
		||||
        """Creates a snapshot and returns the file name containing the data"""
 | 
			
		||||
 | 
			
		||||
        LOG.debug("Taking prometheus data snapshot")
 | 
			
		||||
        ret = self._post("admin/tsdb/snapshot")
 | 
			
		||||
        return ret["data"]["name"]
 | 
			
		||||
							
								
								
									
										110
									
								
								observabilityclient/utils/metric_utils.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								observabilityclient/utils/metric_utils.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,110 @@
 | 
			
		||||
#   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 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"
 | 
			
		||||
LOG = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ConfigurationError(Exception):
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_config_file():
 | 
			
		||||
    if os.path.exists(CONFIG_FILE_NAME):
 | 
			
		||||
        LOG.debug(f"Using {CONFIG_FILE_NAME} as prometheus configuration")
 | 
			
		||||
        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")
 | 
			
		||||
            return open(full_filename, "r")
 | 
			
		||||
    return None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_prometheus_client():
 | 
			
		||||
    host = None
 | 
			
		||||
    port = None
 | 
			
		||||
    conf_file = get_config_file()
 | 
			
		||||
    if conf_file is not None:
 | 
			
		||||
        conf = yaml.safe_load(conf_file)
 | 
			
		||||
        if 'host' in conf:
 | 
			
		||||
            host = conf['host']
 | 
			
		||||
        if 'port' in conf:
 | 
			
		||||
            port = conf['port']
 | 
			
		||||
        conf_file.close()
 | 
			
		||||
 | 
			
		||||
    # NOTE(jwysogla): We allow to overide the prometheus.yaml by
 | 
			
		||||
    #                 the environment variables
 | 
			
		||||
    if 'PROMETHEUS_HOST' in os.environ:
 | 
			
		||||
        host = os.environ['PROMETHEUS_HOST']
 | 
			
		||||
    if 'PROMETHEUS_PORT' in os.environ:
 | 
			
		||||
        port = os.environ['PROMETHEUS_PORT']
 | 
			
		||||
    if host is None or port is None:
 | 
			
		||||
        raise ConfigurationError("Can't find prometheus host and "
 | 
			
		||||
                                 "port configuration.")
 | 
			
		||||
    return PrometheusAPIClient(f"{host}:{port}")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
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:
 | 
			
		||||
            string = string.replace("''", "'")
 | 
			
		||||
        if '""' in string:
 | 
			
		||||
            string = string.replace('""', '"')
 | 
			
		||||
        return string
 | 
			
		||||
 | 
			
		||||
    ret = ""
 | 
			
		||||
    for key, value in d.items():
 | 
			
		||||
        ret += "{}='{}', ".format(key, value)
 | 
			
		||||
    ret = ret[0:-2]
 | 
			
		||||
    old = ""
 | 
			
		||||
    while ret != old:
 | 
			
		||||
        old = ret
 | 
			
		||||
        ret = replace_doubled_quotes(ret)
 | 
			
		||||
    return ret
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def metrics2cols(m):
 | 
			
		||||
    cols = []
 | 
			
		||||
    fields = []
 | 
			
		||||
    first = True
 | 
			
		||||
    for metric in m:
 | 
			
		||||
        row = []
 | 
			
		||||
        for key, value in metric.labels.items():
 | 
			
		||||
            if first:
 | 
			
		||||
                cols.append(key)
 | 
			
		||||
            row.append(value)
 | 
			
		||||
        if first:
 | 
			
		||||
            cols.append("value")
 | 
			
		||||
        row.append(metric.value)
 | 
			
		||||
        fields.append(row)
 | 
			
		||||
        first = False
 | 
			
		||||
    return cols, fields
 | 
			
		||||
@@ -1,201 +0,0 @@
 | 
			
		||||
#   Copyright 2022 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 ansible_runner
 | 
			
		||||
import configparser
 | 
			
		||||
import os
 | 
			
		||||
import shutil
 | 
			
		||||
 | 
			
		||||
from ansible.inventory.manager import InventoryManager
 | 
			
		||||
from ansible.parsing.dataloader import DataLoader
 | 
			
		||||
from ansible.vars.manager import VariableManager
 | 
			
		||||
 | 
			
		||||
from observabilityclient.utils import shell
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AnsibleRunnerException(Exception):
 | 
			
		||||
    """Base exception class for runner exceptions"""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AnsibleRunnerFailed(AnsibleRunnerException):
 | 
			
		||||
    """Raised when ansible run failed"""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, status, rc, stderr):
 | 
			
		||||
        super(AnsibleRunnerFailed).__init__()
 | 
			
		||||
        self.status = status
 | 
			
		||||
        self.rc = rc
 | 
			
		||||
        self.stderr = stderr
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return ('Ansible run failed with status {}'
 | 
			
		||||
                ' (return code {}):\n{}').format(self.status, self.rc,
 | 
			
		||||
                                                 self.stderr)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def parse_inventory_hosts(inventory):
 | 
			
		||||
    """Returns list of dictionaries. Each dictionary contains info about
 | 
			
		||||
    single node from inventory.
 | 
			
		||||
    """
 | 
			
		||||
    dl = DataLoader()
 | 
			
		||||
    if isinstance(inventory, str):
 | 
			
		||||
        inventory = [inventory]
 | 
			
		||||
    im = InventoryManager(loader=dl, sources=inventory)
 | 
			
		||||
    vm = VariableManager(loader=dl, inventory=im)
 | 
			
		||||
 | 
			
		||||
    out = []
 | 
			
		||||
    for host in im.get_hosts():
 | 
			
		||||
        data = vm.get_vars(host=host)
 | 
			
		||||
        out.append(
 | 
			
		||||
            dict(host=data.get('inventory_hostname', str(host)),
 | 
			
		||||
                 ip=data.get('ctlplane_ip', data.get('ansible_host')),
 | 
			
		||||
                 hostname=data.get('canonical_hostname'))
 | 
			
		||||
        )
 | 
			
		||||
    return out
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AnsibleRunner:
 | 
			
		||||
    """Simple wrapper for ansible-playbook."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, workdir: str, moduledir: str = None,
 | 
			
		||||
                 ssh_user: str = 'root', ssh_key: str = None,
 | 
			
		||||
                 ansible_cfg: str = None):
 | 
			
		||||
        """
 | 
			
		||||
        :param workdir: Location of the working directory.
 | 
			
		||||
        :type workdir: String
 | 
			
		||||
 | 
			
		||||
        :param ssh_user: User for the ssh connection.
 | 
			
		||||
        :type ssh_user: String
 | 
			
		||||
 | 
			
		||||
        :param ssh_key: Private key to use for the ssh connection.
 | 
			
		||||
        :type ssh_key: String
 | 
			
		||||
 | 
			
		||||
        :param moduledir: Location of the ansible module and library.
 | 
			
		||||
        :type moduledir: String
 | 
			
		||||
 | 
			
		||||
        :param ansible_cfg: Path to an ansible configuration file.
 | 
			
		||||
        :type ansible_cfg: String
 | 
			
		||||
        """
 | 
			
		||||
        self.workdir = shell.file_check(workdir, ftype='directory')
 | 
			
		||||
 | 
			
		||||
        if moduledir is None:
 | 
			
		||||
            moduledir = ''
 | 
			
		||||
        ansible_cfg = ansible_cfg or os.path.join(workdir, 'ansible.cfg')
 | 
			
		||||
        if not os.path.exists(ansible_cfg):
 | 
			
		||||
            conf = dict(
 | 
			
		||||
                ssh_connection=dict(
 | 
			
		||||
                    ssh_args=(
 | 
			
		||||
                        '-o UserKnownHostsFile={} '
 | 
			
		||||
                        '-o StrictHostKeyChecking=no '
 | 
			
		||||
                        '-o ControlMaster=auto '
 | 
			
		||||
                        '-o ControlPersist=30m '
 | 
			
		||||
                        '-o ServerAliveInterval=64 '
 | 
			
		||||
                        '-o ServerAliveCountMax=1024 '
 | 
			
		||||
                        '-o Compression=no '
 | 
			
		||||
                        '-o TCPKeepAlive=yes '
 | 
			
		||||
                        '-o VerifyHostKeyDNS=no '
 | 
			
		||||
                        '-o ForwardX11=no '
 | 
			
		||||
                        '-o ForwardAgent=yes '
 | 
			
		||||
                        '-o PreferredAuthentications=publickey '
 | 
			
		||||
                        '-T'
 | 
			
		||||
                    ).format(os.devnull),
 | 
			
		||||
                    retries=3,
 | 
			
		||||
                    timeout=30,
 | 
			
		||||
                    scp_if_ssh=True,
 | 
			
		||||
                    pipelining=True
 | 
			
		||||
                ),
 | 
			
		||||
                defaults=dict(
 | 
			
		||||
                    deprecation_warnings=False,
 | 
			
		||||
                    remote_user=ssh_user,
 | 
			
		||||
                    private_key_file=ssh_key,
 | 
			
		||||
                    library=os.path.expanduser(
 | 
			
		||||
                        '~/.ansible/plugins/modules:{workdir}/modules:'
 | 
			
		||||
                        '{userdir}:{ansible}/plugins/modules:'
 | 
			
		||||
                        '{ansible}-modules'.format(
 | 
			
		||||
                            userdir=moduledir, workdir=workdir,
 | 
			
		||||
                            ansible='/usr/share/ansible'
 | 
			
		||||
                        )
 | 
			
		||||
                    ),
 | 
			
		||||
                    lookup_plugins=os.path.expanduser(
 | 
			
		||||
                        '~/.ansible/plugins/lookup:{workdir}/lookup:'
 | 
			
		||||
                        '{ansible}/plugins/lookup:'.format(
 | 
			
		||||
                            workdir=workdir, ansible='/usr/share/ansible'
 | 
			
		||||
                        )
 | 
			
		||||
                    ),
 | 
			
		||||
                    gathering='smart',
 | 
			
		||||
                    log_path=shell.file_check(
 | 
			
		||||
                        os.path.join(workdir, 'ansible.log'),
 | 
			
		||||
                        clear=True
 | 
			
		||||
                    )
 | 
			
		||||
                ),
 | 
			
		||||
            )
 | 
			
		||||
            parser = configparser.ConfigParser()
 | 
			
		||||
            parser.read_dict(conf)
 | 
			
		||||
            with open(ansible_cfg, 'w') as conffile:
 | 
			
		||||
                parser.write(conffile)
 | 
			
		||||
        os.environ['ANSIBLE_CONFIG'] = ansible_cfg
 | 
			
		||||
 | 
			
		||||
    def run(self, playbook, tags: str = None, skip_tags: str = None,
 | 
			
		||||
            timeout: int = 30, quiet: bool = False, debug: bool = False):
 | 
			
		||||
        """Run given Ansible playbook.
 | 
			
		||||
 | 
			
		||||
        :param playbook: Playbook filename.
 | 
			
		||||
        :type playbook: String
 | 
			
		||||
 | 
			
		||||
        :param tags: Run specific tags.
 | 
			
		||||
        :type tags: String
 | 
			
		||||
 | 
			
		||||
        :param skip_tags: Skip specific tags.
 | 
			
		||||
        :type skip_tags: String
 | 
			
		||||
 | 
			
		||||
        :param timeout: Timeout to finish playbook execution (minutes).
 | 
			
		||||
        :type timeout: int
 | 
			
		||||
 | 
			
		||||
        :param quiet: Disable all output (Defaults to False)
 | 
			
		||||
        :type quiet: Boolean
 | 
			
		||||
 | 
			
		||||
        :param debug: Enable debug output (Defaults to False)
 | 
			
		||||
        :type quiet: Boolean
 | 
			
		||||
        """
 | 
			
		||||
        kwargs = {
 | 
			
		||||
            'private_data_dir': self.workdir,
 | 
			
		||||
            'verbosity': 3 if debug else 0,
 | 
			
		||||
        }
 | 
			
		||||
        locs = locals()
 | 
			
		||||
        for arg in ['playbook', 'tags', 'skip_tags', 'quiet']:
 | 
			
		||||
            if locs[arg] is not None:
 | 
			
		||||
                kwargs[arg] = locs[arg]
 | 
			
		||||
        run_conf = ansible_runner.runner_config.RunnerConfig(**kwargs)
 | 
			
		||||
        run_conf.prepare()
 | 
			
		||||
        run = ansible_runner.Runner(config=run_conf)
 | 
			
		||||
        try:
 | 
			
		||||
            status, rc = run.run()
 | 
			
		||||
        finally:
 | 
			
		||||
            if status in ['failed', 'timeout', 'canceled'] or rc != 0:
 | 
			
		||||
                err = getattr(run, 'stderr', getattr(run, 'stdout', None))
 | 
			
		||||
                if err:
 | 
			
		||||
                    error = err.read()
 | 
			
		||||
                else:
 | 
			
		||||
                    error = "Ansible failed with status %s" % status
 | 
			
		||||
                raise AnsibleRunnerFailed(status, rc, error)
 | 
			
		||||
 | 
			
		||||
    def destroy(self, clear: bool = False):
 | 
			
		||||
        """Cleans environment after Ansible run.
 | 
			
		||||
 | 
			
		||||
        :param clear: Clear also workdir
 | 
			
		||||
        :type clear: Boolean
 | 
			
		||||
        """
 | 
			
		||||
        del os.environ['ANSIBLE_CONFIG']
 | 
			
		||||
        if clear:
 | 
			
		||||
            shutil.rmtree(self.workdir, ignore_errors=True)
 | 
			
		||||
@@ -1,75 +0,0 @@
 | 
			
		||||
#   Copyright 2022 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
 | 
			
		||||
import pipes
 | 
			
		||||
import shutil
 | 
			
		||||
import subprocess
 | 
			
		||||
import tempfile
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
from contextlib import contextmanager
 | 
			
		||||
from observabilityclient.utils import strings
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@contextmanager
 | 
			
		||||
def tempdir(base: str, prefix: str = None, clear: bool = True) -> str:
 | 
			
		||||
    path = tempfile.mkdtemp(prefix=prefix, dir=base)
 | 
			
		||||
    try:
 | 
			
		||||
        yield path
 | 
			
		||||
    finally:
 | 
			
		||||
        if clear:
 | 
			
		||||
            shutil.rmtree(path, ignore_errors=True)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def file_check(path: str, ftype: str = 'file', clear: bool = False) -> str:
 | 
			
		||||
    """Check if given path exists and create it in case required."""
 | 
			
		||||
    if not os.path.exists(path) or clear:
 | 
			
		||||
        if ftype == 'directory':
 | 
			
		||||
            if clear:
 | 
			
		||||
                shutil.rmtree(path, ignore_errors=True)
 | 
			
		||||
            os.makedirs(path, mode=0o700, exist_ok=True)
 | 
			
		||||
        elif ftype == 'file':
 | 
			
		||||
            with open(path, 'w') as f:
 | 
			
		||||
                f.close()
 | 
			
		||||
    return path
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def execute(cmd, workdir: str = None, can_fail: bool = True,
 | 
			
		||||
            mask_list: list = None, use_shell: bool = False):
 | 
			
		||||
    """
 | 
			
		||||
    Runs given shell command. Returns return code and content of stdout.
 | 
			
		||||
 | 
			
		||||
    :param workdir: Location of the working directory.
 | 
			
		||||
    :type workdir: String
 | 
			
		||||
 | 
			
		||||
    :param can_fail: If is set to True RuntimeError is raised in case
 | 
			
		||||
                     of command returned non-zero return code.
 | 
			
		||||
    :type can_fail: Boolean
 | 
			
		||||
    """
 | 
			
		||||
    mask_list = mask_list or []
 | 
			
		||||
 | 
			
		||||
    if not isinstance(cmd, str):
 | 
			
		||||
        masked = ' '.join((pipes.quote(i) for i in cmd))
 | 
			
		||||
    else:
 | 
			
		||||
        masked = cmd
 | 
			
		||||
    masked = strings.mask_string(masked, mask_list)
 | 
			
		||||
 | 
			
		||||
    proc = subprocess.Popen(cmd, cwd=workdir, shell=use_shell, close_fds=True,
 | 
			
		||||
                            stdout=subprocess.PIPE, stderr=subprocess.PIPE)
 | 
			
		||||
    out, err = proc.communicate()
 | 
			
		||||
    if proc.returncode and can_fail:
 | 
			
		||||
        raise RuntimeError('Failed to execute command: %s' % masked)
 | 
			
		||||
    return proc.returncode, out, err
 | 
			
		||||
@@ -1,41 +0,0 @@
 | 
			
		||||
#   Copyright 2022 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.
 | 
			
		||||
#
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
STR_MASK = '*' * 8
 | 
			
		||||
COLORS = {'nocolor': "\033[0m",
 | 
			
		||||
          'red': "\033[0;31m",
 | 
			
		||||
          'green': "\033[32m",
 | 
			
		||||
          'blue': "\033[34m",
 | 
			
		||||
          'yellow': "\033[33m"}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def color_text(text, color):
 | 
			
		||||
    """Returns given text string with appropriate color tag. Allowed value
 | 
			
		||||
    for color parameter is 'red', 'blue', 'green' and 'yellow'.
 | 
			
		||||
    """
 | 
			
		||||
    return '%s%s%s' % (COLORS[color], text, COLORS['nocolor'])
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def mask_string(unmasked, mask_list=None):
 | 
			
		||||
    """Replaces words from mask_list with MASK in unmasked string."""
 | 
			
		||||
    mask_list = mask_list or []
 | 
			
		||||
 | 
			
		||||
    masked = unmasked
 | 
			
		||||
    for word in mask_list:
 | 
			
		||||
        if not word:
 | 
			
		||||
            continue
 | 
			
		||||
        masked = masked.replace(word, STR_MASK)
 | 
			
		||||
    return masked
 | 
			
		||||
@@ -13,24 +13,12 @@
 | 
			
		||||
#   under the License.
 | 
			
		||||
#
 | 
			
		||||
 | 
			
		||||
import os
 | 
			
		||||
import shutil
 | 
			
		||||
 | 
			
		||||
from osc_lib.command import command
 | 
			
		||||
from osc_lib.i18n import _
 | 
			
		||||
 | 
			
		||||
from observabilityclient.utils import runner
 | 
			
		||||
from observabilityclient.utils import shell
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
OBSLIBDIR = shell.file_check('/usr/share/osp-observability', 'directory')
 | 
			
		||||
OBSWRKDIR = shell.file_check(
 | 
			
		||||
    os.path.expanduser('~/.osp-observability'), 'directory'
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ObservabilityBaseCommand(command.Command):
 | 
			
		||||
    """Base class for observability commands."""
 | 
			
		||||
    """Base class for metric commands."""
 | 
			
		||||
 | 
			
		||||
    def get_parser(self, prog_name):
 | 
			
		||||
        parser = super().get_parser(prog_name)
 | 
			
		||||
@@ -44,68 +32,50 @@ class ObservabilityBaseCommand(command.Command):
 | 
			
		||||
            action='store_true',
 | 
			
		||||
            help=_("Disable cleanup of temporary files.")
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # TODO(jwysogla): Should this be restricted somehow?
 | 
			
		||||
        parser.add_argument(
 | 
			
		||||
            '--workdir',
 | 
			
		||||
            default=OBSWRKDIR,
 | 
			
		||||
            help=_("Working directory for observability commands.")
 | 
			
		||||
        )
 | 
			
		||||
        parser.add_argument(
 | 
			
		||||
            '--moduledir',
 | 
			
		||||
            default=None,
 | 
			
		||||
            help=_("Directory with additional Ansible modules.")
 | 
			
		||||
        )
 | 
			
		||||
        parser.add_argument(
 | 
			
		||||
            '--ssh-user',
 | 
			
		||||
            default='heat-admin',
 | 
			
		||||
            help=_("Username to be used for SSH connection.")
 | 
			
		||||
        )
 | 
			
		||||
        parser.add_argument(
 | 
			
		||||
            '--ssh-key',
 | 
			
		||||
            default='/home/stack/.ssh/id_rsa',
 | 
			
		||||
            help=_("SSH private key to be used for SSH connection.")
 | 
			
		||||
        )
 | 
			
		||||
        parser.add_argument(
 | 
			
		||||
            '--ansible-cfg',
 | 
			
		||||
            default=os.path.join(OBSWRKDIR, 'ansible.cfg'),
 | 
			
		||||
            help=_("Path to Ansible configuration.")
 | 
			
		||||
        )
 | 
			
		||||
        parser.add_argument(
 | 
			
		||||
            '--config',
 | 
			
		||||
            default=None,
 | 
			
		||||
            help=_("Path to playbook configuration file.")
 | 
			
		||||
            '--disable-rbac',
 | 
			
		||||
            action='store_true',
 | 
			
		||||
            help=_("Disable rbac injection")
 | 
			
		||||
        )
 | 
			
		||||
        return parser
 | 
			
		||||
 | 
			
		||||
    def _run_playbook(self, playbook, inventory, parsed_args):
 | 
			
		||||
        """Run Ansible raw playbook"""
 | 
			
		||||
        playbook = os.path.join(OBSLIBDIR, 'playbooks', playbook)
 | 
			
		||||
        with shell.tempdir(parsed_args.workdir,
 | 
			
		||||
                           prefix=os.path.splitext(playbook)[0],
 | 
			
		||||
                           clear=not parsed_args.messy) as tmpdir:
 | 
			
		||||
            # copy extravars file for the playbook run
 | 
			
		||||
            if parsed_args.config:
 | 
			
		||||
                envdir = shell.file_check(os.path.join(tmpdir, 'env'),
 | 
			
		||||
                                          'directory')
 | 
			
		||||
                shutil.copy(parsed_args.config,
 | 
			
		||||
                            os.path.join(envdir, 'extravars'))
 | 
			
		||||
            # copy inventory file for the playbook run
 | 
			
		||||
            shutil.copy(inventory, os.path.join(tmpdir, 'inventory'))
 | 
			
		||||
            # run playbook
 | 
			
		||||
            rnr = runner.AnsibleRunner(tmpdir,
 | 
			
		||||
                                       moduledir=parsed_args.moduledir,
 | 
			
		||||
                                       ssh_user=parsed_args.ssh_user,
 | 
			
		||||
                                       ssh_key=parsed_args.ssh_key,
 | 
			
		||||
                                       ansible_cfg=parsed_args.ansible_cfg)
 | 
			
		||||
            if parsed_args.messy:
 | 
			
		||||
                print("Running playbook %s" % playbook)
 | 
			
		||||
            rnr.run(playbook, debug=parsed_args.dev)
 | 
			
		||||
            rnr.destroy(clear=not parsed_args.messy)
 | 
			
		||||
 | 
			
		||||
    def _execute(self, command, parsed_args):
 | 
			
		||||
        """Execute local command"""
 | 
			
		||||
        with shell.tempdir(parsed_args.workdir, prefix='exec',
 | 
			
		||||
                           clear=not parsed_args.messy) as tmpdir:
 | 
			
		||||
            rc, out, err = shell.execute(command, workdir=tmpdir,
 | 
			
		||||
                                         can_fail=parsed_args.dev,
 | 
			
		||||
                                         use_shell=True)
 | 
			
		||||
        return rc, out, err
 | 
			
		||||
class Manager(object):
 | 
			
		||||
    """Base class for the python api."""
 | 
			
		||||
    DEFAULT_HEADERS = {
 | 
			
		||||
        "Accept": "application/json",
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    def __init__(self, client):
 | 
			
		||||
        self.client = client
 | 
			
		||||
        self.prom = client.prometheus_client
 | 
			
		||||
 | 
			
		||||
    def _set_default_headers(self, kwargs):
 | 
			
		||||
        headers = kwargs.get('headers', {})
 | 
			
		||||
        for k, v in self.DEFAULT_HEADERS.items():
 | 
			
		||||
            if k not in headers:
 | 
			
		||||
                headers[k] = v
 | 
			
		||||
        kwargs['headers'] = headers
 | 
			
		||||
        return kwargs
 | 
			
		||||
 | 
			
		||||
    def _get(self, *args, **kwargs):
 | 
			
		||||
        self._set_default_headers(kwargs)
 | 
			
		||||
        return self.client.api.get(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def _post(self, *args, **kwargs):
 | 
			
		||||
        self._set_default_headers(kwargs)
 | 
			
		||||
        return self.client.api.post(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def _put(self, *args, **kwargs):
 | 
			
		||||
        self._set_default_headers(kwargs)
 | 
			
		||||
        return self.client.api.put(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def _patch(self, *args, **kwargs):
 | 
			
		||||
        self._set_default_headers(kwargs)
 | 
			
		||||
        return self.client.api.patch(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def _delete(self, *args, **kwargs):
 | 
			
		||||
        self._set_default_headers(kwargs)
 | 
			
		||||
        return self.client.api.delete(*args, **kwargs)
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										109
									
								
								observabilityclient/v1/cli.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								observabilityclient/v1/cli.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,109 @@
 | 
			
		||||
#   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 observabilityclient.utils import metric_utils
 | 
			
		||||
from observabilityclient.v1 import base
 | 
			
		||||
from osc_lib.i18n import _
 | 
			
		||||
 | 
			
		||||
from cliff import lister
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class List(base.ObservabilityBaseCommand, lister.Lister):
 | 
			
		||||
    """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)
 | 
			
		||||
        return ["metric_name"], [[m] for m in metrics]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Show(base.ObservabilityBaseCommand, lister.Lister):
 | 
			
		||||
    """Query prometheus for the current value of metric"""
 | 
			
		||||
 | 
			
		||||
    def get_parser(self, prog_name):
 | 
			
		||||
        parser = super().get_parser(prog_name)
 | 
			
		||||
        parser.add_argument(
 | 
			
		||||
                'name',
 | 
			
		||||
                help=_("Name of the metric to show"))
 | 
			
		||||
        return parser
 | 
			
		||||
 | 
			
		||||
    def take_action(self, parsed_args):
 | 
			
		||||
        client = metric_utils.get_client(self)
 | 
			
		||||
        metric = client.query.show(parsed_args.name,
 | 
			
		||||
                                   disable_rbac=parsed_args.disable_rbac)
 | 
			
		||||
        return metric_utils.metrics2cols(metric)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Query(base.ObservabilityBaseCommand, lister.Lister):
 | 
			
		||||
    """Query prometheus with a custom query string"""
 | 
			
		||||
 | 
			
		||||
    def get_parser(self, prog_name):
 | 
			
		||||
        parser = super().get_parser(prog_name)
 | 
			
		||||
        parser.add_argument(
 | 
			
		||||
                'query',
 | 
			
		||||
                help=_("Custom PromQL query"))
 | 
			
		||||
        return parser
 | 
			
		||||
 | 
			
		||||
    def take_action(self, parsed_args):
 | 
			
		||||
        client = metric_utils.get_client(self)
 | 
			
		||||
        metric = client.query.query(parsed_args.query,
 | 
			
		||||
                                    disable_rbac=parsed_args.disable_rbac)
 | 
			
		||||
        ret = metric_utils.metrics2cols(metric)
 | 
			
		||||
        return ret
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Delete(base.ObservabilityBaseCommand):
 | 
			
		||||
    """Delete data for a selected series and time range"""
 | 
			
		||||
    def get_parser(self, prog_name):
 | 
			
		||||
        parser = super().get_parser(prog_name)
 | 
			
		||||
        parser.add_argument(
 | 
			
		||||
                'matches',
 | 
			
		||||
                action="append",
 | 
			
		||||
                nargs='+',
 | 
			
		||||
                help=_("Series selector, that selects the series to delete. "
 | 
			
		||||
                       "Specify multiple selectors delimited by space to "
 | 
			
		||||
                       "delete multiple series."))
 | 
			
		||||
        parser.add_argument(
 | 
			
		||||
                '--start',
 | 
			
		||||
                help=_("Start timestamp in rfc3339 or unix timestamp. "
 | 
			
		||||
                       "Defaults to minimum possible timestamp."))
 | 
			
		||||
        parser.add_argument(
 | 
			
		||||
                '--end',
 | 
			
		||||
                help=_("End timestamp in rfc3339 or unix timestamp. "
 | 
			
		||||
                       "Defaults to maximum possible timestamp."))
 | 
			
		||||
        return parser
 | 
			
		||||
 | 
			
		||||
    def take_action(self, parsed_args):
 | 
			
		||||
        client = metric_utils.get_client(self)
 | 
			
		||||
        return client.query.delete(parsed_args.matches,
 | 
			
		||||
                                   parsed_args.start,
 | 
			
		||||
                                   parsed_args.end)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CleanTombstones(base.ObservabilityBaseCommand):
 | 
			
		||||
    """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
 | 
			
		||||
 | 
			
		||||
    def take_action(self, parsed_args):
 | 
			
		||||
        client = metric_utils.get_client(self)
 | 
			
		||||
        return client.query.clean_tombstones()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Snapshot(base.ObservabilityBaseCommand, lister.Lister):
 | 
			
		||||
    def take_action(self, parsed_args):
 | 
			
		||||
        client = metric_utils.get_client(self)
 | 
			
		||||
        ret = client.query.snapshot()
 | 
			
		||||
        return ["Snapshot file name"], [[ret]]
 | 
			
		||||
							
								
								
									
										43
									
								
								observabilityclient/v1/client.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								observabilityclient/v1/client.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,43 @@
 | 
			
		||||
#   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 keystoneauth1.session
 | 
			
		||||
 | 
			
		||||
from observabilityclient.utils.metric_utils import get_prometheus_client
 | 
			
		||||
from observabilityclient.v1 import python_api
 | 
			
		||||
from observabilityclient.v1 import rbac
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Client(object):
 | 
			
		||||
    """Client for the observabilityclient api"""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, session=None, adapter_options=None,
 | 
			
		||||
                 session_options=None, disable_rbac=False):
 | 
			
		||||
        """Initialize a new client for the Observabilityclient v1 API."""
 | 
			
		||||
        session_options = session_options or {}
 | 
			
		||||
        adapter_options = adapter_options or {}
 | 
			
		||||
 | 
			
		||||
        adapter_options.setdefault('service_type', "metric")
 | 
			
		||||
 | 
			
		||||
        if session is None:
 | 
			
		||||
            session = keystoneauth1.session.Session(**session_options)
 | 
			
		||||
        else:
 | 
			
		||||
            if session_options:
 | 
			
		||||
                raise ValueError("session and session_options are exclusive")
 | 
			
		||||
 | 
			
		||||
        self.session = session
 | 
			
		||||
 | 
			
		||||
        self.prometheus_client = get_prometheus_client()
 | 
			
		||||
        self.query = python_api.QueryManager(self)
 | 
			
		||||
        self.rbac = rbac.Rbac(self, self.session, disable_rbac)
 | 
			
		||||
@@ -1,180 +0,0 @@
 | 
			
		||||
#   Copyright 2022 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
 | 
			
		||||
import requests
 | 
			
		||||
import shutil
 | 
			
		||||
import sys
 | 
			
		||||
import yaml
 | 
			
		||||
 | 
			
		||||
from osc_lib.i18n import _
 | 
			
		||||
 | 
			
		||||
from observabilityclient.v1 import base
 | 
			
		||||
from observabilityclient.utils import runner
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class InventoryError(Exception):
 | 
			
		||||
    def __init__(self, err, out):
 | 
			
		||||
        self.err = err
 | 
			
		||||
        self.out = out
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return ('Failed to generate or locate Ansible '
 | 
			
		||||
                'inventory file:\n%s\n%s' % (self.err or '', self.out))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
INVENTORY = os.path.join(base.OBSWRKDIR, 'openstack-inventory.yaml')
 | 
			
		||||
INV_FALLBACKS = [
 | 
			
		||||
    '~/tripleo-deploy/{stack}/openstack-inventory.yaml',
 | 
			
		||||
    '~/tripleo-deploy/{stack}/tripleo-ansible-inventory.yaml',
 | 
			
		||||
    './overcloud-deploy/{stack}/openstack-inventory.yaml',
 | 
			
		||||
    './overcloud-deploy/{stack}/tripleo-ansible-inventory.yaml',
 | 
			
		||||
]
 | 
			
		||||
ENDPOINTS = os.path.join(base.OBSWRKDIR, 'scrape-endpoints.yaml')
 | 
			
		||||
STACKRC = os.path.join(base.OBSWRKDIR, 'stackrc')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _curl(host: dict, port: int, timeout: int = 1) -> str:
 | 
			
		||||
    """Returns scraping endpoint URL if it is reachable
 | 
			
		||||
    otherwise returns None."""
 | 
			
		||||
    url = f'http://{host["ip"]}:{port}/metrics'
 | 
			
		||||
    try:
 | 
			
		||||
        r = requests.get(url, timeout=1)
 | 
			
		||||
        if r.status_code != 200:
 | 
			
		||||
            url = None
 | 
			
		||||
        r.close()
 | 
			
		||||
    except requests.exceptions.ConnectionError:
 | 
			
		||||
        url = None
 | 
			
		||||
    return url
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Discover(base.ObservabilityBaseCommand):
 | 
			
		||||
    """Generate Ansible inventory file and scrapable enpoints list file."""
 | 
			
		||||
 | 
			
		||||
    def get_parser(self, prog_name):
 | 
			
		||||
        parser = super().get_parser(prog_name)
 | 
			
		||||
        parser.add_argument(
 | 
			
		||||
            '--scrape',
 | 
			
		||||
            action='append',
 | 
			
		||||
            default=['collectd/9103'],
 | 
			
		||||
            help=_("Service/Port of scrape endpoint to check on nodes")
 | 
			
		||||
        )
 | 
			
		||||
        parser.add_argument(
 | 
			
		||||
            '--stack-name',
 | 
			
		||||
            default='overcloud',
 | 
			
		||||
            help=_("Overcloud stack name for which inventory file should "
 | 
			
		||||
                   "be generated")
 | 
			
		||||
        )
 | 
			
		||||
        parser.add_argument(
 | 
			
		||||
            '--inventory',
 | 
			
		||||
            help=_("Use this argument in case you have inventory file "
 | 
			
		||||
                   "generated or moved to non-standard place. Value has to be "
 | 
			
		||||
                   "path to inventory file including the file name.")
 | 
			
		||||
        )
 | 
			
		||||
        return parser
 | 
			
		||||
 | 
			
		||||
    def take_action(self, parsed_args):
 | 
			
		||||
        # discover undercloud and overcloud nodes
 | 
			
		||||
        try:
 | 
			
		||||
            rc, out, err = self._execute(
 | 
			
		||||
                'tripleo-ansible-inventory '
 | 
			
		||||
                '--static-yaml-inventory {} '
 | 
			
		||||
                '--stack {}'.format(INVENTORY, parsed_args.stack_name),
 | 
			
		||||
                parsed_args
 | 
			
		||||
            )
 | 
			
		||||
            if rc:
 | 
			
		||||
                raise InventoryError(err, out)
 | 
			
		||||
 | 
			
		||||
            # OSP versions with deprecated tripleo-ansible-inventory fallbacks
 | 
			
		||||
            # to static inventory file generated at one of the fallback path
 | 
			
		||||
            if not os.path.exists(INVENTORY):
 | 
			
		||||
                if parsed_args.inventory:
 | 
			
		||||
                    INV_FALLBACKS.insert(0, parsed_args.inventory)
 | 
			
		||||
                for i in INV_FALLBACKS:
 | 
			
		||||
                    absi = i.format(stack=parsed_args.stack_name)
 | 
			
		||||
                    absi = os.path.abspath(os.path.expanduser(absi))
 | 
			
		||||
                    if os.path.exists(absi):
 | 
			
		||||
                        shutil.copyfile(absi, INVENTORY)
 | 
			
		||||
                        break
 | 
			
		||||
                else:
 | 
			
		||||
                    raise InventoryError('None of the fallback inventory files'
 | 
			
		||||
                                         ' exists: %s' % INV_FALLBACKS, '')
 | 
			
		||||
        except InventoryError as ex:
 | 
			
		||||
            print(str(ex))
 | 
			
		||||
            sys.exit(1)
 | 
			
		||||
 | 
			
		||||
        # discover scrape endpoints
 | 
			
		||||
        endpoints = dict()
 | 
			
		||||
        hosts = runner.parse_inventory_hosts(INVENTORY)
 | 
			
		||||
        for scrape in parsed_args.scrape:
 | 
			
		||||
            service, port = scrape.split('/')
 | 
			
		||||
            for host in hosts:
 | 
			
		||||
                if parsed_args.dev:
 | 
			
		||||
                    name = host["hostname"] if host["hostname"] else host["ip"]
 | 
			
		||||
                    print(f'Trying to fetch {service} metrics on host '
 | 
			
		||||
                          f'{name} at port {port}', end='')
 | 
			
		||||
                node = _curl(host, port, timeout=1)
 | 
			
		||||
                if node:
 | 
			
		||||
                    endpoints.setdefault(service.strip(), []).append(node)
 | 
			
		||||
                if parsed_args.dev:
 | 
			
		||||
                    print(' [success]' if node else ' [failure]')
 | 
			
		||||
        data = yaml.safe_dump(endpoints, default_flow_style=False)
 | 
			
		||||
        with open(ENDPOINTS, 'w') as f:
 | 
			
		||||
            f.write(data)
 | 
			
		||||
        print("Discovered following scraping endpoints:\n%s" % data)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Setup(base.ObservabilityBaseCommand):
 | 
			
		||||
    """Install and configure given Observability component(s)"""
 | 
			
		||||
 | 
			
		||||
    auth_required = False
 | 
			
		||||
 | 
			
		||||
    def get_parser(self, prog_name):
 | 
			
		||||
        parser = super().get_parser(prog_name)
 | 
			
		||||
        parser.add_argument(
 | 
			
		||||
            'components',
 | 
			
		||||
            nargs='+',
 | 
			
		||||
            choices=[
 | 
			
		||||
                'prometheus_agent',
 | 
			
		||||
                # TODO: in future will contain option for all stack components
 | 
			
		||||
            ]
 | 
			
		||||
        )
 | 
			
		||||
        parser.add_argument(
 | 
			
		||||
            '--inventory',
 | 
			
		||||
            help=_("Use this argument in case you don't want to use for "
 | 
			
		||||
                   "whatever reason the inventory file generated by discovery "
 | 
			
		||||
                   "command")
 | 
			
		||||
        )
 | 
			
		||||
        return parser
 | 
			
		||||
 | 
			
		||||
    def take_action(self, parsed_args):
 | 
			
		||||
        inventory = INVENTORY
 | 
			
		||||
        if parsed_args.inventory:
 | 
			
		||||
            inventory = parsed_args.inventory
 | 
			
		||||
        for compnt in parsed_args.components:
 | 
			
		||||
            playbook = '%s.yml' % compnt
 | 
			
		||||
            try:
 | 
			
		||||
                self._run_playbook(playbook, inventory,
 | 
			
		||||
                                   parsed_args=parsed_args)
 | 
			
		||||
            except OSError as ex:
 | 
			
		||||
                print('Failed to load playbook file: %s' % ex)
 | 
			
		||||
                sys.exit(1)
 | 
			
		||||
            except yaml.YAMLError as ex:
 | 
			
		||||
                print('Failed to parse playbook configuration: %s' % ex)
 | 
			
		||||
                sys.exit(1)
 | 
			
		||||
            except runner.AnsibleRunnerFailed as ex:
 | 
			
		||||
                print('Ansible run %s (rc %d)' % (ex.status, ex.rc))
 | 
			
		||||
                if parsed_args.dev:
 | 
			
		||||
                    print(ex.stderr)
 | 
			
		||||
							
								
								
									
										96
									
								
								observabilityclient/v1/python_api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								observabilityclient/v1/python_api.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,96 @@
 | 
			
		||||
#   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 observabilityclient.utils.metric_utils import format_labels
 | 
			
		||||
from observabilityclient.v1 import base
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class QueryManager(base.Manager):
 | 
			
		||||
    def list(self, disable_rbac=False):
 | 
			
		||||
        """Lists 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
 | 
			
		||||
        else:
 | 
			
		||||
            match = f"{{{format_labels(self.client.rbac.default_labels)}}}"
 | 
			
		||||
            metrics = self.prom.series(match)
 | 
			
		||||
            if metrics == []:
 | 
			
		||||
                return []
 | 
			
		||||
            unique_metric_names = list(set([m['__name__'] for m in metrics]))
 | 
			
		||||
            return sorted(unique_metric_names)
 | 
			
		||||
 | 
			
		||||
    def show(self, name, disable_rbac=False):
 | 
			
		||||
        """Shows current values for metrics of a specified name
 | 
			
		||||
 | 
			
		||||
        :param disable_rbac: Disables rbac injection if set to True
 | 
			
		||||
        :type disable_rbac: boolean
 | 
			
		||||
        """
 | 
			
		||||
        enriched = self.client.rbac.append_rbac(name,
 | 
			
		||||
                                                disable_rbac=disable_rbac)
 | 
			
		||||
        last_metric_query = f"last_over_time({enriched}[5m])"
 | 
			
		||||
        return self.prom.query(last_metric_query)
 | 
			
		||||
 | 
			
		||||
    def query(self, query, disable_rbac=False):
 | 
			
		||||
        """Sends 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.
 | 
			
		||||
        Having labels as part of a query is allowed.
 | 
			
		||||
 | 
			
		||||
        A call like this:
 | 
			
		||||
        query("sum(name1) - sum(name2{label1='value'})")
 | 
			
		||||
        will result in a query string like this:
 | 
			
		||||
        "sum(name1{rbac='rbac_value'}) -
 | 
			
		||||
            sum(name2{label1='value', rbac='rbac_value'})"
 | 
			
		||||
 | 
			
		||||
        :param query: Custom query string
 | 
			
		||||
        :type query: str
 | 
			
		||||
        :param disable_rbac: Disables rbac injection if set to True
 | 
			
		||||
        :type disable_rbac: boolean
 | 
			
		||||
        """
 | 
			
		||||
        query = self.client.rbac.enrich_query(query, disable_rbac)
 | 
			
		||||
        return self.prom.query(query)
 | 
			
		||||
 | 
			
		||||
    def delete(self, matches, start=None, end=None):
 | 
			
		||||
        """Deletes 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
 | 
			
		||||
        minimum and maximum timestamps are used.
 | 
			
		||||
 | 
			
		||||
        :param matches: List of matches to match which metrics to delete
 | 
			
		||||
        :type matches: [str]
 | 
			
		||||
        :param start: timestamp from which to start deleting
 | 
			
		||||
        :type start: rfc3339 or unix_timestamp
 | 
			
		||||
        :param end: timestamp until which to delete
 | 
			
		||||
        :type end: rfc3339 or unix_timestamp
 | 
			
		||||
        """
 | 
			
		||||
        # TODO(jwysogla) Do we want to restrict access to the admin api
 | 
			
		||||
        #                endpoints? We could either try to inject
 | 
			
		||||
        #                the project label like in query. We could also
 | 
			
		||||
        #                do some check right here, before
 | 
			
		||||
        #                it gets to prometheus.
 | 
			
		||||
        return self.prom.delete(matches, start, end)
 | 
			
		||||
 | 
			
		||||
    def clean_tombstones(self):
 | 
			
		||||
        """Instructs prometheus to clean tombstones"""
 | 
			
		||||
        return self.prom.clean_tombstones()
 | 
			
		||||
 | 
			
		||||
    def snapshot(self):
 | 
			
		||||
        "Creates a snapshot of the current data"
 | 
			
		||||
        return self.prom.snapshot()
 | 
			
		||||
							
								
								
									
										139
									
								
								observabilityclient/v1/rbac.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										139
									
								
								observabilityclient/v1/rbac.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,139 @@
 | 
			
		||||
#   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 keystoneauth1.exceptions.auth_plugins import MissingAuthPlugin
 | 
			
		||||
from observabilityclient.utils.metric_utils import format_labels
 | 
			
		||||
import re
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ObservabilityRbacError(Exception):
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Rbac():
 | 
			
		||||
    def __init__(self, client, session, disable_rbac=False):
 | 
			
		||||
        self.client = client
 | 
			
		||||
        self.session = session
 | 
			
		||||
        self.disable_rbac = disable_rbac
 | 
			
		||||
        try:
 | 
			
		||||
            self.project_id = self.session.get_project_id()
 | 
			
		||||
            self.default_labels = {
 | 
			
		||||
                    "project": self.project_id
 | 
			
		||||
                    }
 | 
			
		||||
            self.rbac_init_successful = True
 | 
			
		||||
        except MissingAuthPlugin:
 | 
			
		||||
            self.project_id = None
 | 
			
		||||
            self.default_labels = {
 | 
			
		||||
                    "project": "no-project"
 | 
			
		||||
                    }
 | 
			
		||||
            self.rbac_init_successful = False
 | 
			
		||||
 | 
			
		||||
    def _find_label_value_end(self, query, start, quote_char):
 | 
			
		||||
        end = start
 | 
			
		||||
        while (end == start or
 | 
			
		||||
               query[end - 1] == '\\'):
 | 
			
		||||
            # Looking for first unescaped quotes
 | 
			
		||||
            end = query.find(quote_char, end + 1)
 | 
			
		||||
        # returns the quote position or -1
 | 
			
		||||
        return end
 | 
			
		||||
 | 
			
		||||
    def _find_label_pair_end(self, query, start):
 | 
			
		||||
        eq_sign_pos = query.find('=', start)
 | 
			
		||||
        quote_char = "'"
 | 
			
		||||
        quote_start_pos = query.find(quote_char, eq_sign_pos)
 | 
			
		||||
        if quote_start_pos == -1:
 | 
			
		||||
            quote_char = '"'
 | 
			
		||||
            quote_start_pos = query.find(quote_char, eq_sign_pos)
 | 
			
		||||
        end = self._find_label_value_end(query, quote_start_pos, quote_char)
 | 
			
		||||
        # returns the pair end or -1
 | 
			
		||||
        return end
 | 
			
		||||
 | 
			
		||||
    def _find_label_section_end(self, query, start):
 | 
			
		||||
        nearest_curly_brace_pos = None
 | 
			
		||||
        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 "=",
 | 
			
		||||
                # then we must be at the end of the label section
 | 
			
		||||
                # and the "=" 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
 | 
			
		||||
 | 
			
		||||
        :param query: The query to enrich
 | 
			
		||||
        :type query: str
 | 
			
		||||
        :param disable_rbac: Disables rbac injection if set to True
 | 
			
		||||
        :type disable_rbac: boolean
 | 
			
		||||
        """
 | 
			
		||||
        # TODO(jwysogla): This should be properly tested
 | 
			
		||||
        if disable_rbac:
 | 
			
		||||
            return query
 | 
			
		||||
        labels = self.default_labels
 | 
			
		||||
 | 
			
		||||
        # We need to get all metric names, no matter the rbac
 | 
			
		||||
        metric_names = self.client.query.list(disable_rbac=False)
 | 
			
		||||
 | 
			
		||||
        # We need to detect the locations of metric names
 | 
			
		||||
        # inside the query
 | 
			
		||||
        # NOTE the locations are the locations within the original query
 | 
			
		||||
        name_end_locations = []
 | 
			
		||||
        for name in metric_names:
 | 
			
		||||
            # Regex for a metric name is: [a-zA-Z_:][a-zA-Z0-9_:]*
 | 
			
		||||
            # We need to make sure, that "name" isn't just a part
 | 
			
		||||
            # of a longer word, so we try to expand it by "name_regex"
 | 
			
		||||
            name_regex = "[a-zA-Z_:]?[a-zA-Z0-9_:]*" + name + "[a-zA-Z0-9_:]*"
 | 
			
		||||
            potential_names = re.finditer(name_regex, query)
 | 
			
		||||
            for potential_name in potential_names:
 | 
			
		||||
                if potential_name.group(0) == name:
 | 
			
		||||
                    name_end_locations.append(potential_name.end())
 | 
			
		||||
 | 
			
		||||
        name_end_locations = sorted(name_end_locations, reverse=True)
 | 
			
		||||
        for name_end_location in name_end_locations:
 | 
			
		||||
            if (name_end_location < len(query) and
 | 
			
		||||
               query[name_end_location] == "{"):
 | 
			
		||||
                # There already are some labels
 | 
			
		||||
                labels_end = self._find_label_section_end(query,
 | 
			
		||||
                                                          name_end_location)
 | 
			
		||||
                query = (f"{query[:labels_end]}, "
 | 
			
		||||
                         f"{format_labels(labels)}"
 | 
			
		||||
                         f"{query[labels_end:]}")
 | 
			
		||||
            else:
 | 
			
		||||
                query = (f"{query[:name_end_location]}"
 | 
			
		||||
                         f"{{{format_labels(labels)}}}"
 | 
			
		||||
                         f"{query[name_end_location:]}")
 | 
			
		||||
        return query
 | 
			
		||||
 | 
			
		||||
    def append_rbac(self, query, disable_rbac=False):
 | 
			
		||||
        """Used to 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
 | 
			
		||||
        of complex queries, where metric names might occure elsewhere than
 | 
			
		||||
        just at the end, please use the enrich_query() function.
 | 
			
		||||
 | 
			
		||||
        :param query: The query to append to
 | 
			
		||||
        :type query: str
 | 
			
		||||
        :param disable_rbac: Disables rbac injection if set to True
 | 
			
		||||
        :type disable_rbac: boolean
 | 
			
		||||
        """
 | 
			
		||||
        labels = self.default_labels
 | 
			
		||||
        if disable_rbac:
 | 
			
		||||
            return query
 | 
			
		||||
        return f"{query}{{{format_labels(labels)}}}"
 | 
			
		||||
							
								
								
									
										14
									
								
								setup.cfg
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								setup.cfg
									
									
									
									
									
								
							@@ -2,7 +2,7 @@
 | 
			
		||||
name = python-observabilityclient
 | 
			
		||||
summary = OpenStack Observability Client
 | 
			
		||||
description_file =
 | 
			
		||||
    README.rst
 | 
			
		||||
    README.md
 | 
			
		||||
license = Apache License, Version 2.0
 | 
			
		||||
author = OpenStack
 | 
			
		||||
author_email = openstack-discuss@lists.openstack.org
 | 
			
		||||
@@ -35,12 +35,12 @@ openstack.cli.extension =
 | 
			
		||||
    observabilityclient = observabilityclient.plugin
 | 
			
		||||
 | 
			
		||||
openstack.observabilityclient.v1 =
 | 
			
		||||
    observability_discover = observabilityclient.v1.deploy:Discover
 | 
			
		||||
    observability_setup = observabilityclient.v1.deploy:Setup
 | 
			
		||||
#    observability_upgrade = observabilityclient.v1.deploy:Upgrade
 | 
			
		||||
 | 
			
		||||
#    metrics_list = observabilityclient.v1.metrics:List
 | 
			
		||||
#    metrics_get = observabilityclient.v1.metrics:Get
 | 
			
		||||
    metric_list = observabilityclient.v1.cli:List
 | 
			
		||||
    metric_show = observabilityclient.v1.cli:Show
 | 
			
		||||
    metric_query = observabilityclient.v1.cli:Query
 | 
			
		||||
    metric_delete = observabilityclient.v1.cli:Delete
 | 
			
		||||
    metric_clean-tombstones = observabilityclient.v1.cli:CleanTombstones
 | 
			
		||||
    metric_snapshot = observabilityclient.v1.cli:Snapshot
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
[flake8]
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user