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
	 Jaromír Wysoglad
					Jaromír Wysoglad