././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1740768577.8672588 python_observabilityclient-0.4.0/0000775000175000017500000000000000000000000017214 5ustar00zuulzuul00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/.zuul.yaml0000664000175000017500000000443300000000000021161 0ustar00zuulzuul00000000000000- job: # TODO(jwysogla): Include these tests in the # telemetry-dsvm-integration jobs name: observabilityclient-dsvm-functional parent: devstack-tox-functional description: | Devstack-based functional tests for observabilityclient. required-projects: - openstack/python-observabilityclient - openstack/ceilometer - infrawatch/sg-core timeout: 4200 vars: devstack_localrc: USE_PYTHON3: True PROMETHEUS_SERVICE_SCRAPE_TARGETS: prometheus,sg-core CEILOMETER_BACKEND: sg-core devstack_plugins: sg-core: https://github.com/infrawatch/sg-core ceilometer: https://opendev.org/openstack/ceilometer - project: queue: telemetry templates: - openstack-python3-jobs - check-requirements - release-notes-jobs-python3 check: jobs: - telemetry-dsvm-integration: irrelevant-files: &pobsc-irrelevant-files - ^(test-|)requirements.txt$ - ^setup.cfg$ - ^.*\.rst$ - ^releasenotes/.*$ - ^observabilityclient/tests/.*$ - ^tools/.*$ - ^tox.ini$ voting: false - telemetry-dsvm-integration-ipv6-only: irrelevant-files: *pobsc-irrelevant-files voting: false - telemetry-dsvm-integration-centos-9s: irrelevant-files: *pobsc-irrelevant-files voting: false - telemetry-dsvm-integration-centos-9s-fips: irrelevant-files: *pobsc-irrelevant-files voting: false - observabilityclient-dsvm-functional: irrelevant-files: *pobsc-irrelevant-files gate: jobs: - telemetry-dsvm-integration: irrelevant-files: *pobsc-irrelevant-files voting: false - telemetry-dsvm-integration-ipv6-only: irrelevant-files: *pobsc-irrelevant-files voting: false - telemetry-dsvm-integration-centos-9s: irrelevant-files: *pobsc-irrelevant-files voting: false - telemetry-dsvm-integration-centos-9s-fips: irrelevant-files: *pobsc-irrelevant-files voting: false - observabilityclient-dsvm-functional: irrelevant-files: *pobsc-irrelevant-files ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768577.0 python_observabilityclient-0.4.0/AUTHORS0000664000175000017500000000066000000000000020266 0ustar00zuulzuul00000000000000Alfredo Moralejo Chris Sibbitt Erno Kuvaja Ghanshyam Mann Jaromir Wysoglad Jaromír Wysoglad Leif Madsen Leif Madsen Marihan Girgis mgirgisf@redhat.com Martin Magr Martin Mágr Takashi Kajinami ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768577.0 python_observabilityclient-0.4.0/ChangeLog0000664000175000017500000000216700000000000020774 0ustar00zuulzuul00000000000000CHANGES ======= 0.4.0 ----- * Fix release note build * Remove Python 3.8 support * Use requirements.txt solely to manage dependencies * Add stestr as test requirement 0.3.0 ----- * Drop direct usage of simplejson * Use stestr for testing 0.2.0 ----- * Sort column order in cli output * Fix table formatter * Remove unused test dependencies * Remove AUTHORS file * Add TLS support * Drop unused --dev and --messy 0.1.1 ----- * Update python classifier in setup.cfg * Add functional tests * Fix cli commands * Add i18n.py and use it in v1/base.py and v1/cli.py * Fix "\_" is shadowing Python builtin * Flake8 changes * Remove .github/ 0.1.0 ----- * Fix zuul testing * Add automated unit testing and a set of tests (#9) 0.0.4 ----- * Fix setup.cfg * PyPI dist (#8) 0.0.3 ----- * PyPI dist (#8) 0.0.2 ----- * Prometheus interaction (#7) * Update README.md * Update README to reflect new params file changes * Removing README.rst * Merging additions from my notes * Fixed missing comma 0.0.1 ----- * Update license file * Basic set of docs * Missing piece * Allow inventory overrides * Initial functionality (#1) * Initial commit ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/LICENSE0000664000175000017500000002611700000000000020230 0ustar00zuulzuul00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. 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. ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1740768577.8672588 python_observabilityclient-0.4.0/PKG-INFO0000644000175000017500000000563400000000000020317 0ustar00zuulzuul00000000000000Metadata-Version: 2.1 Name: python-observabilityclient Version: 0.4.0 Summary: OpenStack Observability Client Home-page: https://infrawatch.github.io/documentation/ Author: OpenStack Author-email: openstack-discuss@lists.openstack.org License: Apache License, Version 2.0 Classifier: Environment :: Console Classifier: Environment :: OpenStack Classifier: Intended Audience :: Developers Classifier: Intended Audience :: Information Technology Classifier: Intended Audience :: System Administrators Classifier: License :: OSI Approved :: Apache Software License Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: 3 :: Only Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Requires-Python: >=3.9 Description-Content-Type: text/markdown; charset=UTF-8 License-File: LICENSE Requires-Dist: osc-lib>=1.0.1 Requires-Dist: keystoneauth1>=1.0.0 Requires-Dist: cliff>=1.14.0 Requires-Dist: PyYAML>5.1 # python-observabilityclient observabilityclient is an OpenStackClient (OSC) plugin implementation that implements commands for management of Prometheus. ## Development Install your OpenStack environment and patch your `openstack` client application using python. ``` # if using standalone, the following commands come after 'sudo dnf install -y python3-tripleoclient' su - stack # clone and install observability client plugin git clone https://github.com/infrawatch/python-observabilityclient cd python-observabilityclient sudo python setup.py install --prefix=/usr ``` ## Usage Use `openstack metric query somequery` to query for metrics in prometheus. To use the python api do the following: ``` from observabilityclient import client 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") ``` ## List of commands 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 ## 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 ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/README.md0000664000175000017500000000326700000000000020503 0ustar00zuulzuul00000000000000# python-observabilityclient observabilityclient is an OpenStackClient (OSC) plugin implementation that implements commands for management of Prometheus. ## Development Install your OpenStack environment and patch your `openstack` client application using python. ``` # if using standalone, the following commands come after 'sudo dnf install -y python3-tripleoclient' su - stack # clone and install observability client plugin git clone https://github.com/infrawatch/python-observabilityclient cd python-observabilityclient sudo python setup.py install --prefix=/usr ``` ## Usage Use `openstack metric query somequery` to query for metrics in prometheus. To use the python api do the following: ``` from observabilityclient import client 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") ``` ## List of commands 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 ## 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 ././@PaxHeader0000000000000000000000000000003300000000000011451 xustar000000000000000027 mtime=1740768577.855259 python_observabilityclient-0.4.0/doc/0000775000175000017500000000000000000000000017761 5ustar00zuulzuul00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/doc/requirements.txt0000664000175000017500000000012400000000000023242 0ustar00zuulzuul00000000000000sphinx>=2.1.1 # BSD openstackdocstheme>=2.2.1 # Apache-2.0 reno>=1.6.2 # Apache-2.0 ././@PaxHeader0000000000000000000000000000003300000000000011451 xustar000000000000000027 mtime=1740768577.855259 python_observabilityclient-0.4.0/observabilityclient/0000775000175000017500000000000000000000000023271 5ustar00zuulzuul00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/observabilityclient/__init__.py0000664000175000017500000000000000000000000025370 0ustar00zuulzuul00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/observabilityclient/client.py0000664000175000017500000000147400000000000025127 0ustar00zuulzuul00000000000000# 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) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/observabilityclient/i18n.py0000664000175000017500000000142600000000000024425 0ustar00zuulzuul00000000000000# Copyright 2023 OpenStack Foundation # # 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 oslo_i18n _translators = oslo_i18n.TranslatorFactory(domain='observabilityclient') # The primary translation function using the well-known name "_" _ = _translators.primary ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/observabilityclient/plugin.py0000664000175000017500000000432600000000000025146 0ustar00zuulzuul00000000000000# 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.""" from osc_lib import utils DEFAULT_API_VERSION = '1' API_NAME = 'observabilityclient' API_VERSION_OPTION = 'os_observabilityclient_api_version' API_VERSIONS = { '1': 'observabilityclient.v1.client.Client', } def make_client(instance): """Return a client to the ClientManager. Called to instantiate the requested client version. instance has any available auth info that may be required to prepare the client. :param ClientManager instance: The ClientManager that owns the new client """ observability_client = utils.get_client_class( API_NAME, instance._api_version[API_NAME], API_VERSIONS) client = observability_client(session=instance.session, adapter_options={ 'interface': instance.interface, 'region_name': instance.region_name }) return client def build_option_parser(parser): """Add global options. Called from openstackclient.shell.OpenStackShell.__init__() after the builtin parser has been initialized. This is where a plugin can add global options such as an API version setting. :param argparse.ArgumentParser parser: The parser object that has been initialized by OpenStackShell. """ parser.add_argument( '--os-observability-api-version', metavar='', help='Observability Plugin API version, default=' + DEFAULT_API_VERSION + ' (Env: OS_OSCPLUGIN_API_VERSION)') return parser ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/observabilityclient/prometheus_client.py0000664000175000017500000001561400000000000027403 0ustar00zuulzuul00000000000000# 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: try: decoded = self.resp.json() if 'error' in decoded: return f'[{self.resp.status_code}] {decoded["error"]}' except requests.JSONDecodeError: # If an https endpoint is accessed as http, # we get 400 status with plain text instead of # json and decoding it raises exception. return f'[{self.resp.status_code}] {self.resp.text}' return f'[{self.resp.status_code}] {self.resp.reason}' else: decoded = self.resp.json() return f'[{decoded.status}]' def __repr__(self) -> str: return self.__str__() class PrometheusMetric(object): def __init__(self, input): self.timestamp = input['value'][0] self.labels = input['metric'] self.value = input['value'][1] class PrometheusAPIClient(object): 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): """Send custom queries to Prometheus. :param query: the query to send :type query: str """ LOG.debug("Querying prometheus with query: %s", 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): """Query the /series/ endpoint of prometheus. :param matches: List of matches to send as parameters :type matches: [str] """ LOG.debug("Querying prometheus for series with matches: %s", matches) decoded = self._get("series", {"match[]": matches}) return decoded['data'] def labels(self): """Query the /labels/ endpoint of prometheus, returns list of labels. There isn't a way to tell prometheus to restrict which labels to return. It's not possible to enforce rbac with this for example. """ LOG.debug("Querying prometheus for labels") decoded = self._get("labels") return decoded['data'] def label_values(self, label): """Query prometheus for values of a specified label. :param label: Name of label for which to return values :type label: str """ LOG.debug("Querying prometheus for the values of label: %s", label) decoded = self._get(f"label/{label}/values") return decoded['data'] # --------- # admin api # --------- def delete(self, matches, start=None, end=None): """Delete 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("Deleting metrics from prometheus matching: %s", 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): """Ask 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): """Create a snapshot and return the file name containing the data.""" LOG.debug("Taking prometheus data snapshot") ret = self._post("admin/tsdb/snapshot") return ret["data"]["name"] ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1740768577.8472588 python_observabilityclient-0.4.0/observabilityclient/tests/0000775000175000017500000000000000000000000024433 5ustar00zuulzuul00000000000000././@PaxHeader0000000000000000000000000000003300000000000011451 xustar000000000000000027 mtime=1740768577.855259 python_observabilityclient-0.4.0/observabilityclient/tests/functional/0000775000175000017500000000000000000000000026575 5ustar00zuulzuul00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/observabilityclient/tests/functional/__init__.py0000664000175000017500000000000000000000000030674 0ustar00zuulzuul00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/observabilityclient/tests/functional/base.py0000664000175000017500000001457300000000000030073 0ustar00zuulzuul00000000000000# 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. # base.py file taken and modified from the openstackclient functional tests import json import logging import os import shlex import subprocess from observabilityclient import client from keystoneauth1 import loading from keystoneauth1 import session import os_client_config from tempest.lib.cli import output_parser from tempest.lib import exceptions import testtools ADMIN_CLOUD = os.environ.get('OS_ADMIN_CLOUD', 'devstack-admin') LOG = logging.getLogger(__name__) class PythonAPITestCase(testtools.TestCase): def _getKeystoneSession(self): conf = os_client_config.OpenStackConfig() creds = conf.get_one_cloud(cloud=ADMIN_CLOUD).get_auth_args() ks_creds = dict( auth_url=creds["auth_url"], username=creds["username"], password=creds["password"], project_name=creds["project_name"], user_domain_id=creds["user_domain_id"], project_domain_id=creds["project_domain_id"]) loader = loading.get_plugin_loader("password") auth = loader.load_from_options(**ks_creds) return session.Session(auth=auth) def setUp(self): super(PythonAPITestCase, self).setUp() self.client = client.Client( 1, self._getKeystoneSession() ) def execute(cmd, fail_ok=False, merge_stderr=False): """Execute specified command for the given action.""" LOG.debug('Executing: %s', cmd) cmdlist = shlex.split(cmd) stdout = subprocess.PIPE stderr = subprocess.STDOUT if merge_stderr else subprocess.PIPE proc = subprocess.Popen(cmdlist, stdout=stdout, stderr=stderr) result_out, result_err = proc.communicate() result_out = result_out.decode('utf-8') LOG.debug('stdout: %s', result_out) LOG.debug('stderr: %s', result_err) if not fail_ok and proc.returncode != 0: raise exceptions.CommandFailed( proc.returncode, cmd, result_out, result_err, ) return result_out class CliTestCase(testtools.TestCase): @classmethod def openstack( cls, cmd, *, cloud=ADMIN_CLOUD, fail_ok=False, parse_output=False, ): """Execute observabilityclient command for the given action. :param cmd: A string representation of the command to execute. :param cloud: The cloud to execute against. This can be a string, empty string, or None. A string results in '--os-auth-type $cloud', an empty string results in the '--os-auth-type' option being omitted, and None resuts in '--os-auth-type none' for legacy reasons. :param fail_ok: If failure is permitted. If False (default), a command failure will result in `~tempest.lib.exceptions.CommandFailed` being raised. :param parse_output: If true, pass the '-f json' parameter and decode the output. :returns: The output from the command. :raises: `~tempest.lib.exceptions.CommandFailed` if the command failed and ``fail_ok`` was ``False``. """ auth_args = [] if cloud is None: # Execute command with no auth auth_args.append('--os-auth-type none') elif cloud != '': # Execute command with an explicit cloud specified auth_args.append(f'--os-cloud {cloud}') format_args = [] if parse_output: format_args.append('-f json') output = execute( ' '.join(['openstack'] + auth_args + [cmd] + format_args), fail_ok=fail_ok, ) if parse_output: return json.loads(output) else: return output @classmethod def assertOutput(cls, expected, actual): if expected != actual: raise Exception(expected + ' != ' + actual) @classmethod def assertInOutput(cls, expected, actual): if expected not in actual: raise Exception(expected + ' not in ' + actual) @classmethod def assertNotInOutput(cls, expected, actual): if expected in actual: raise Exception(expected + ' in ' + actual) @classmethod def assertsOutputNotNone(cls, observed): if observed is None: raise Exception('No output observed') def assert_table_structure(self, items, field_names): """Verify that all items have keys listed in field_names.""" for item in items: for field in field_names: self.assertIn(field, item) def assert_show_fields(self, show_output, field_names): """Verify that all items have keys listed in field_names.""" # field_names = ['name', 'description'] # show_output = [{'name': 'fc2b98d8faed4126b9e371eda045ade2'}, # {'description': 'description-821397086'}] # this next line creates a flattened list of all 'keys' (like 'name', # and 'description' out of the output all_headers = [item for sublist in show_output for item in sublist] for field_name in field_names: self.assertIn(field_name, all_headers) def parse_show_as_object(self, raw_output): """Return a dict with values parsed from cli output.""" items = self.parse_show(raw_output) o = {} for item in items: o.update(item) return o def parse_show(self, raw_output): """Return list of dicts with item values parsed from cli output.""" items = [] table_ = output_parser.table(raw_output) for row in table_['values']: item = {} item[row[0]] = row[1] items.append(item) return items def parse_listing(self, raw_output): """Return list of dicts with basic item parsed from cli output.""" return output_parser.listing(raw_output) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/observabilityclient/tests/functional/test_cli.py0000664000175000017500000001137000000000000030757 0ustar00zuulzuul00000000000000# 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.tests.functional import base import time class CliTestFunctionalRBACDisabled(base.CliTestCase): """Functional tests for cli commands with disabled RBAC.""" def test_list(self): cmd_output = self.openstack( 'metric list --disable-rbac', parse_output=True, ) name_list = [item.get('metric_name') for item in cmd_output] self.assertIn( 'up', name_list ) def test_show(self): cmd_output = self.openstack( 'metric show up --disable-rbac', parse_output=True, ) for metric in cmd_output: self.assertEqual( "up", metric["__name__"] ) self.assertEqual( "1", metric["value"] ) def test_query(self): cmd_output = self.openstack( 'metric query up --disable-rbac', parse_output=True, ) for metric in cmd_output: self.assertEqual( "up", metric["__name__"] ) self.assertEqual( "1", metric["value"] ) class CliTestFunctionalRBACEnabled(base.CliTestCase): """Functional tests for cli commands with enabled RBAC.""" def test_list(self): cmd_output = self.openstack( 'metric list', parse_output=True, ) name_list = [item.get('metric_name') for item in cmd_output] self.assertIn( 'ceilometer_image_size', name_list ) self.assertNotIn( 'up', name_list ) def test_show(self): cmd_output = self.openstack( 'metric show ceilometer_image_size', parse_output=True, ) for metric in cmd_output: self.assertEqual( "ceilometer_image_size", metric["__name__"] ) self.assertEqual( "sg-core", metric["job"] ) def test_query(self): cmd_output = self.openstack( 'metric query ceilometer_image_size', parse_output=True, ) for metric in cmd_output: self.assertEqual( "ceilometer_image_size", metric["__name__"] ) self.assertEqual( "sg-core", metric["job"] ) class CliTestFunctionalAdminCommands(base.CliTestCase): """Functional tests for cli admin commands.""" def test_delete(self): test_start_time = int(time.time()) query_before = self.openstack( f'metric query prometheus_ready@{test_start_time} --disable-rbac', parse_output=True, ) values = [item.get("__name__") for item in query_before] # Check, that the metric is present before the deletion self.assertIn( "prometheus_ready", values ) self.openstack( 'metric delete prometheus_ready --disable-rbac', parse_output=False, ) query_after = self.openstack( f'metric query prometheus_ready@{test_start_time} --disable-rbac', parse_output=True, ) values = [item.get("__name__") for item in query_after] # Check, that the metric is not present after the deletion self.assertNotIn( "prometheus_ready", values ) def test_clean_tombstones(self): # NOTE(jwysogla) There is not much to check here # except for the fact, that the command doesn't # raise an exception. Prometheus doesn't send any # data back and we don't have a reliable way to query # prometheus that this command did something. self.openstack('metric clean-tombstones') def test_snapshot(self): cmd_output = self.openstack( 'metric snapshot', parse_output=True, ) for name in cmd_output: self.assertInOutput( time.strftime('%Y%m%d'), name.get("Snapshot file name") ) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/observabilityclient/tests/functional/test_python_api.py0000664000175000017500000000635600000000000032372 0ustar00zuulzuul00000000000000# 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.tests.functional import base import time class PythonAPITestFunctionalRBACDisabled(base.PythonAPITestCase): def test_list(self): ret = self.client.query.list(disable_rbac=True) self.assertIn("up", ret) def test_show(self): ret = self.client.query.show("up", disable_rbac=True) for metric in ret: self.assertEqual("up", metric.labels["__name__"]) self.assertEqual("1", metric.value) def test_query(self): ret = self.client.query.query("up", disable_rbac=True) for metric in ret: self.assertEqual("up", metric.labels["__name__"]) self.assertEqual("1", metric.value) class PythonAPITestFunctionalRBACEnabled(base.PythonAPITestCase): def test_list(self): ret = self.client.query.list(disable_rbac=False) self.assertIn("ceilometer_image_size", ret) self.assertNotIn("up", ret) def test_show(self): ret = self.client.query.show("ceilometer_image_size", disable_rbac=False) for metric in ret: self.assertEqual("ceilometer_image_size", metric.labels["__name__"]) self.assertEqual("sg-core", metric.labels["job"]) def test_query(self): ret = self.client.query.query("ceilometer_image_size", disable_rbac=False) for metric in ret: self.assertEqual("ceilometer_image_size", metric.labels["__name__"]) self.assertEqual("sg-core", metric.labels["job"]) class PythonAPITestFunctionalAdminCommands(base.PythonAPITestCase): def test_delete(self): now = time.time() metric_name = "prometheus_build_info" query = f"{metric_name}@{now}" query_before = self.client.query.query(query, disable_rbac=True) for metric in query_before: self.assertEqual(metric_name, metric.labels["__name__"]) self.client.query.delete(metric_name) query_after = self.client.query.query(query, disable_rbac=True) self.assertEqual([], query_after) def test_clean_tombstones(self): # NOTE(jwysogla) There is not much to check here # except for the fact, that the command doesn't # raise an exception. Prometheus doesn't send any # data back and we don't have a reliable way to query # prometheus that this command did something. self.client.query.clean_tombstones() def test_snapshot(self): ret = self.client.query.snapshot() self.assertIn(time.strftime("%Y%m%d"), ret) ././@PaxHeader0000000000000000000000000000003300000000000011451 xustar000000000000000027 mtime=1740768577.859259 python_observabilityclient-0.4.0/observabilityclient/tests/unit/0000775000175000017500000000000000000000000025412 5ustar00zuulzuul00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/observabilityclient/tests/unit/__init__.py0000664000175000017500000000000000000000000027511 0ustar00zuulzuul00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/observabilityclient/tests/unit/test_cli.py0000664000175000017500000001351400000000000027576 0ustar00zuulzuul00000000000000# Copyright 2023 Red Hat, Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. from unittest import mock import testtools from observabilityclient.prometheus_client import PrometheusMetric from observabilityclient.utils import metric_utils from observabilityclient.v1 import cli class CliTest(testtools.TestCase): def setUp(self): super(CliTest, self).setUp() self.client = mock.Mock() self.client.query = mock.Mock() def test_list(self): metric_names = ['name1', 'name2', 'name3'] expected = (['metric_name'], [['name1'], ['name2'], ['name3']]) cli_list = cli.List(mock.Mock(), mock.Mock()) parser = cli_list.get_parser("metric list") test_parsed_args_enabled = parser.parse_args([ ]) test_parsed_args_disabled = parser.parse_args([ "--disable-rbac" ]) with mock.patch.object(metric_utils, 'get_client', return_value=self.client), \ mock.patch.object(self.client.query, 'list', return_value=metric_names) as m: ret1 = cli_list.take_action(test_parsed_args_enabled) m.assert_called_with(disable_rbac=False) ret2 = cli_list.take_action(test_parsed_args_disabled) m.assert_called_with(disable_rbac=True) self.assertEqual(ret1, expected) self.assertEqual(ret2, expected) def test_show(self): metric = { 'value': [123456, 12], 'metric': {'label1': 'value1'} } prom_metric = [PrometheusMetric(metric)] expected = ['label1', 'value'], [['value1', 12]] cli_show = cli.Show(mock.Mock(), mock.Mock()) parser = cli_show.get_parser("metric show") test_parsed_args_enabled = parser.parse_args([ "metric_name" ]) test_parsed_args_disabled = parser.parse_args([ "metric_name", "--disable-rbac" ]) with mock.patch.object(metric_utils, 'get_client', return_value=self.client), \ mock.patch.object(self.client.query, 'show', return_value=prom_metric) as m: ret1 = cli_show.take_action(test_parsed_args_enabled) m.assert_called_with('metric_name', disable_rbac=False) ret2 = cli_show.take_action(test_parsed_args_disabled) m.assert_called_with('metric_name', disable_rbac=True) self.assertEqual(ret1, expected) self.assertEqual(ret2, expected) def test_query(self): query = ("some_query{label!~'not_this_value'} - " "sum(second_metric{label='this'})") metric = { 'value': [123456, 12], 'metric': {'label1': 'value1'} } prom_metric = [PrometheusMetric(metric)] expected = ['label1', 'value'], [['value1', 12]] cli_query = cli.Query(mock.Mock(), mock.Mock()) parser = cli_query.get_parser("metric query") test_parsed_args_enabled = parser.parse_args([ query ]) test_parsed_args_disabled = parser.parse_args([ query, "--disable-rbac" ]) with mock.patch.object(metric_utils, 'get_client', return_value=self.client), \ mock.patch.object(self.client.query, 'query', return_value=prom_metric) as m: ret1 = cli_query.take_action(test_parsed_args_enabled) m.assert_called_with(query, disable_rbac=False) ret2 = cli_query.take_action(test_parsed_args_disabled) m.assert_called_with(query, disable_rbac=True) self.assertEqual(ret1, expected) self.assertEqual(ret2, expected) def test_delete(self): match1 = "some_label_name" match2 = "some_label_name2" cli_delete = cli.Delete(mock.Mock(), mock.Mock()) parser = cli_delete.get_parser("metric delete") test_parsed_args = parser.parse_args([ match1, match2, "--start", "0", "--end", "10" ]) with mock.patch.object(metric_utils, 'get_client', return_value=self.client), \ mock.patch.object(self.client.query, 'delete') as m: cli_delete.take_action(test_parsed_args) m.assert_called_with([[match1, match2]], "0", "10") def test_clean_combstones(self): cli_clean_tombstones = cli.CleanTombstones(mock.Mock(), mock.Mock()) with mock.patch.object(metric_utils, 'get_client', return_value=self.client), \ mock.patch.object(self.client.query, 'clean_tombstones') as m: cli_clean_tombstones.take_action({}) m.assert_called_once() def test_snapshot(self): cli_snapshot = cli.Snapshot(mock.Mock(), mock.Mock()) file_name = 'some_file_name' with mock.patch.object(metric_utils, 'get_client', return_value=self.client), \ mock.patch.object(self.client.query, 'snapshot', return_value=file_name) as m: ret = cli_snapshot.take_action({}) m.assert_called_once() self.assertEqual(ret, (["Snapshot file name"], [[file_name]])) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/observabilityclient/tests/unit/test_prometheus_client.py0000664000175000017500000004456100000000000032566 0ustar00zuulzuul00000000000000# Copyright 2023 Red Hat, Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. from unittest import mock import requests import testtools from observabilityclient import prometheus_client as client class MetricListMatcher(testtools.Matcher): def __init__(self, expected): self.expected = expected def __str__(self): return ("Matches Lists of metrics as returned " "by prometheus_client.PremetheusAPIClient.query") def metric_to_str(self, metric): return (f"Labels: {metric.labels}\n" f"Timestamp: {metric.timestamp}\n" f"Value: {metric.value}") def match(self, observed): if len(self.expected) != len(observed): description = (f"len(expected) != len(observed) because " f"{len(self.expected)} != {len(observed)}") return testtools.matchers.Mismatch(description=description) for e in self.expected: for o in observed: if (e.timestamp == o.timestamp and e.value == o.value and e.labels == o.labels): observed.remove(o) break if len(observed) != 0: description = "Couldn't match the following metrics:\n" for o in observed: description += self.metric_to_str(o) + "\n\n" return testtools.matchers.Mismatch(description=description) return None class PrometheusAPIClientTestBase(testtools.TestCase): def setUp(self): super(PrometheusAPIClientTestBase, self).setUp() class GoodResponse(object): def __init__(self): self.status_code = 200 def json(self): return {"status": "success"} class BadResponse(object): def __init__(self): self.status_code = 500 def json(self): return {"status": "error", "error": "test_error"} class NoContentResponse(object): def __init__(self): self.status_code = 204 def json(self): raise requests.exceptions.JSONDecodeError("No content") class PrometheusAPIClientTest(PrometheusAPIClientTestBase): def test_get(self): url = "test" expected_url = "http://localhost:9090/api/v1/test" params = {"query": "ceilometer_image_size{publisher='localhost'}"} expected_params = params return_value = self.GoodResponse() with mock.patch.object(requests.Session, 'get', return_value=return_value) as m: c = client.PrometheusAPIClient("localhost:9090") c._get(url, params) m.assert_called_with(expected_url, params=expected_params, headers={'Accept': 'application/json'}) def test_get_error(self): url = "test" params = {"query": "ceilometer_image_size{publisher='localhost'}"} return_value = self.BadResponse() with mock.patch.object(requests.Session, 'get', return_value=return_value): c = client.PrometheusAPIClient("localhost:9090") self.assertRaises(client.PrometheusAPIClientError, c._get, url, params) return_value = self.NoContentResponse() with mock.patch.object(requests.Session, 'get', return_value=return_value): c = client.PrometheusAPIClient("localhost:9090") self.assertRaises(client.PrometheusAPIClientError, c._get, url, params) def test_post(self): url = "test" expected_url = "http://localhost:9090/api/v1/test" params = {"query": "ceilometer_image_size{publisher='localhost'}"} expected_params = params return_value = self.GoodResponse() with mock.patch.object(requests.Session, 'post', return_value=return_value) as m: c = client.PrometheusAPIClient("localhost:9090") c._post(url, params) m.assert_called_with(expected_url, params=expected_params, headers={'Accept': 'application/json'}) def test_post_error(self): url = "test" params = {"query": "ceilometer_image_size{publisher='localhost'}"} return_value = self.BadResponse() with mock.patch.object(requests.Session, 'post', return_value=return_value): c = client.PrometheusAPIClient("localhost:9090") self.assertRaises(client.PrometheusAPIClientError, c._post, url, params) return_value = self.NoContentResponse() with mock.patch.object(requests.Session, 'post', return_value=return_value): c = client.PrometheusAPIClient("localhost:9090") self.assertRaises(client.PrometheusAPIClientError, c._post, url, params) class PrometheusAPIClientQueryTest(PrometheusAPIClientTestBase): def setUp(self): super().setUp() class GoodQueryResponse(PrometheusAPIClientTestBase.GoodResponse): def __init__(self): super().__init__() self.result1 = { "metric": { "__name__": "test1", }, "value": [103254, "1"] } self.result2 = { "metric": { "__name__": "test2", }, "value": [103255, "2"] } self.expected = [client.PrometheusMetric(self.result1), client.PrometheusMetric(self.result2)] def json(self): return { "status": "success", "data": { "resultType": "vector", "result": [self.result1, self.result2] } } class EmptyQueryResponse(PrometheusAPIClientTestBase.GoodResponse): def __init__(self): super().__init__() self.expected = [] def json(self): return { "status": "success", "data": { "resultType": "vector", "result": [] } } def test_query(self): query = "ceilometer_image_size{publisher='localhost.localdomain'}" matcher = MetricListMatcher(self.GoodQueryResponse().expected) return_value = self.GoodQueryResponse().json() with mock.patch.object(client.PrometheusAPIClient, '_get', return_value=return_value) as m: c = client.PrometheusAPIClient("localhost:9090") ret = c.query(query) m.assert_called_with("query", {"query": query}) self.assertThat(ret, matcher) return_value = self.EmptyQueryResponse().json() with mock.patch.object(client.PrometheusAPIClient, '_get', return_value=return_value) as m: c = client.PrometheusAPIClient("localhost:9090") ret = c.query(query) self.assertEqual(self.EmptyQueryResponse().expected, ret) def test_query_error(self): query = "ceilometer_image_size{publisher='localhost.localdomain'}" client_exception = client.PrometheusAPIClientError(self.BadResponse()) with mock.patch.object(client.PrometheusAPIClient, '_get', side_effect=client_exception): c = client.PrometheusAPIClient("localhost:9090") self.assertRaises(client.PrometheusAPIClientError, c.query, query) class PrometheusAPIClientSeriesTest(PrometheusAPIClientTestBase): def setUp(self): super().setUp() class GoodSeriesResponse(PrometheusAPIClientTestBase.GoodResponse): def __init__(self): super().__init__() self.data = [{ "__name__": "up", "job": "prometheus", "instance": "localhost:9090" }, { "__name__": "up", "job": "node", "instance": "localhost:9091" }, { "__name__": "process_start_time_seconds", "job": "prometheus", "instance": "localhost:9090" }] self.expected = self.data def json(self): return { "status": "success", "data": self.data } class EmptySeriesResponse(PrometheusAPIClientTestBase.GoodResponse): def __init__(self): super().__init__() self.data = [] self.expected = self.data def json(self): return { "status": "success", "data": self.data } def test_series(self): matches = ["up", "ceilometer_image_size"] return_value = self.GoodSeriesResponse().json() with mock.patch.object(client.PrometheusAPIClient, '_get', return_value=return_value) as m: c = client.PrometheusAPIClient("localhost:9090") ret = c.series(matches) m.assert_called_with("series", {"match[]": matches}) self.assertEqual(ret, self.GoodSeriesResponse().data) return_value = self.EmptySeriesResponse().json() with mock.patch.object(client.PrometheusAPIClient, '_get', return_value=return_value) as m: c = client.PrometheusAPIClient("localhost:9090") ret = c.series(matches) m.assert_called_with("series", {"match[]": matches}) self.assertEqual(ret, self.EmptySeriesResponse().data) def test_series_error(self): matches = ["up", "ceilometer_image_size"] client_exception = client.PrometheusAPIClientError(self.BadResponse()) with mock.patch.object(client.PrometheusAPIClient, '_get', side_effect=client_exception): c = client.PrometheusAPIClient("localhost:9090") self.assertRaises(client.PrometheusAPIClientError, c.series, matches) class PrometheusAPIClientLabelsTest(PrometheusAPIClientTestBase): def setUp(self): super().setUp() class GoodLabelsResponse(PrometheusAPIClientTestBase.GoodResponse): def __init__(self): super().__init__() self.labels = ["up", "job", "project_id"] def json(self): return { "status": "success", "data": self.labels } def test_labels(self): return_value = self.GoodLabelsResponse().json() with mock.patch.object(client.PrometheusAPIClient, '_get', return_value=return_value) as m: c = client.PrometheusAPIClient("localhost:9090") ret = c.labels() m.assert_called_with("labels") self.assertEqual(ret, self.GoodLabelsResponse().labels) def test_labels_error(self): client_exception = client.PrometheusAPIClientError(self.BadResponse()) with mock.patch.object(client.PrometheusAPIClient, '_get', side_effect=client_exception): c = client.PrometheusAPIClient("localhost:9090") self.assertRaises(client.PrometheusAPIClientError, c.labels) class PrometheusAPIClientLabelValuesTest(PrometheusAPIClientTestBase): def setUp(self): super().setUp() class GoodLabelValuesResponse(PrometheusAPIClientTestBase.GoodResponse): def __init__(self): super().__init__() self.values = ["prometheus", "some_other_value"] def json(self): return { "status": "success", "data": self.values } class EmptyLabelValuesResponse(PrometheusAPIClientTestBase.GoodResponse): def __init__(self): super().__init__() self.values = [] def json(self): return { "status": "success", "data": self.values } def test_label_values(self): label_name = "job" return_value = self.GoodLabelValuesResponse().json() with mock.patch.object(client.PrometheusAPIClient, '_get', return_value=return_value) as m: c = client.PrometheusAPIClient("localhost:9090") ret = c.label_values(label_name) m.assert_called_with(f"label/{label_name}/values") self.assertEqual(ret, self.GoodLabelValuesResponse().values) return_value = self.EmptyLabelValuesResponse().json() with mock.patch.object(client.PrometheusAPIClient, '_get', return_value=return_value) as m: c = client.PrometheusAPIClient("localhost:9090") ret = c.label_values(label_name) m.assert_called_with(f"label/{label_name}/values") self.assertEqual(ret, self.EmptyLabelValuesResponse().values) def test_label_values_error(self): label_name = "job" client_exception = client.PrometheusAPIClientError(self.BadResponse()) with mock.patch.object(client.PrometheusAPIClient, '_get', side_effect=client_exception): c = client.PrometheusAPIClient("localhost:9090") self.assertRaises(client.PrometheusAPIClientError, c.label_values, label_name) class PrometheusAPIClientDeleteTest(PrometheusAPIClientTestBase): def setUp(self): super().setUp() class GoodDeleteResponse(PrometheusAPIClientTestBase.NoContentResponse): pass def test_delete(self): matches = ["{job='prometheus'}", "up"] start = 1 end = 12 resp = self.GoodDeleteResponse() post_exception = client.PrometheusAPIClientError(resp) with mock.patch.object(client.PrometheusAPIClient, '_post', side_effect=post_exception) as m: c = client.PrometheusAPIClient("localhost:9090") # _post is expected to raise an exception. It's expected # that the exception is caught inside delete. This # test should run without exception getting out of delete try: c.delete(matches, start, end) except Exception as ex: # noqa: B902 self.fail("Exception risen by delete: " + ex) m.assert_called_with("admin/tsdb/delete_series", {"match[]": matches, "start": start, "end": end}) def test_delete_error(self): matches = ["{job='prometheus'}", "up"] client_exception = client.PrometheusAPIClientError(self.BadResponse()) with mock.patch.object(client.PrometheusAPIClient, '_post', side_effect=client_exception): c = client.PrometheusAPIClient("localhost:9090") self.assertRaises(client.PrometheusAPIClientError, c.delete, matches) class PrometheusAPIClientCleanTombstonesTest(PrometheusAPIClientTestBase): def setUp(self): super().setUp() class GoodCleanTombResponse(PrometheusAPIClientTestBase.NoContentResponse): pass def test_clean_tombstones(self): resp = self.GoodCleanTombResponse() post_exception = client.PrometheusAPIClientError(resp) with mock.patch.object(client.PrometheusAPIClient, '_post', side_effect=post_exception) as m: c = client.PrometheusAPIClient("localhost:9090") # _post is expected to raise an exception. It's expected # that the exception is caught inside clean_tombstones. This # test should run without exception getting out of clean_tombstones try: c.clean_tombstones() except Exception as ex: # noqa: B902 self.fail("Exception risen by clean_tombstones: " + ex) m.assert_called_with("admin/tsdb/clean_tombstones") def test_snapshot_error(self): client_exception = client.PrometheusAPIClientError(self.BadResponse()) with mock.patch.object(client.PrometheusAPIClient, '_post', side_effect=client_exception): c = client.PrometheusAPIClient("localhost:9090") self.assertRaises(client.PrometheusAPIClientError, c.clean_tombstones) class PrometheusAPIClientSnapshotTest(PrometheusAPIClientTestBase): def setUp(self): super().setUp() class GoodSnapshotResponse(PrometheusAPIClientTestBase.NoContentResponse): def __init__(self): super().__init__() self.filename = "somefilename" def json(self): return { "status": "success", "data": { "name": self.filename } } def test_snapshot(self): return_value = self.GoodSnapshotResponse().json() with mock.patch.object(client.PrometheusAPIClient, '_post', return_value=return_value) as m: c = client.PrometheusAPIClient("localhost:9090") ret = c.snapshot() m.assert_called_with("admin/tsdb/snapshot") self.assertEqual(ret, self.GoodSnapshotResponse().filename) def test_snapshot_error(self): client_exception = client.PrometheusAPIClientError(self.BadResponse()) with mock.patch.object(client.PrometheusAPIClient, '_post', side_effect=client_exception): c = client.PrometheusAPIClient("localhost:9090") self.assertRaises(client.PrometheusAPIClientError, c.snapshot) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/observabilityclient/tests/unit/test_python_api.py0000664000175000017500000001123000000000000031172 0ustar00zuulzuul00000000000000# Copyright 2023 Red Hat, Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. from unittest import mock import testtools from observabilityclient import prometheus_client from observabilityclient.tests.unit.test_prometheus_client import ( MetricListMatcher ) from observabilityclient.v1 import python_api from observabilityclient.v1 import rbac class QueryManagerTest(testtools.TestCase): def setUp(self): super(QueryManagerTest, self).setUp() self.client = mock.Mock() prom_client = prometheus_client.PrometheusAPIClient("somehost") self.client.prometheus_client = prom_client self.rbac = mock.Mock(wraps=rbac.Rbac(self.client, mock.Mock())) self.rbac.default_labels = {'project': 'project_id'} self.rbac.rbac_init_succesful = True self.manager = python_api.QueryManager(self.client) self.client.rbac = self.rbac self.client.query = self.manager def test_list(self): returned_by_prom = {'data': ['metric1', 'test42', 'abc2']} expected = ['abc2', 'metric1', 'test42'] with mock.patch.object(prometheus_client.PrometheusAPIClient, '_get', return_value=returned_by_prom): ret1 = self.manager.list() ret2 = self.manager.list(disable_rbac=True) self.assertEqual(expected, ret1) self.assertEqual(expected, ret2) def test_show(self): query = 'some_metric' returned_by_prom = { 'data': { 'resultType': 'non-vector' }, 'value': [1234567, 42], 'metric': { 'label': 'label_value' } } expected = [prometheus_client.PrometheusMetric(returned_by_prom)] expected_matcher = MetricListMatcher(expected) with mock.patch.object(prometheus_client.PrometheusAPIClient, '_get', return_value=returned_by_prom): ret1 = self.manager.show(query) self.rbac.append_rbac.assert_called_with(query, disable_rbac=False) ret2 = self.manager.show(query, disable_rbac=True) self.rbac.append_rbac.assert_called_with(query, disable_rbac=True) self.assertThat(ret1, expected_matcher) self.assertThat(ret2, expected_matcher) def test_query(self): query = 'some_metric' returned_by_prom = { 'data': { 'resultType': 'non-vector' }, 'value': [1234567, 42], 'metric': { 'label': 'label_value' } } expected = [prometheus_client.PrometheusMetric(returned_by_prom)] expected_matcher = MetricListMatcher(expected) with mock.patch.object(prometheus_client.PrometheusAPIClient, '_get', return_value=returned_by_prom): ret1 = self.manager.query(query) self.rbac.enrich_query.assert_called_with(query, disable_rbac=False) ret2 = self.manager.query(query, disable_rbac=True) self.rbac.enrich_query.assert_called_with(query, disable_rbac=True) self.assertThat(ret1, expected_matcher) self.assertThat(ret2, expected_matcher) def test_delete(self): matches = "some_metric" start = 0 end = 100 with mock.patch.object(prometheus_client.PrometheusAPIClient, 'delete') as m: self.manager.delete(matches, start, end) m.assert_called_with(matches, start, end) def test_clean_tombstones(self): with mock.patch.object(prometheus_client.PrometheusAPIClient, 'clean_tombstones') as m: self.manager.clean_tombstones() m.assert_called_once() def test_snapshot(self): with mock.patch.object(prometheus_client.PrometheusAPIClient, 'snapshot') as m: self.manager.snapshot() m.assert_called_once() ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/observabilityclient/tests/unit/test_rbac.py0000664000175000017500000001307400000000000027737 0ustar00zuulzuul00000000000000# Copyright 2023 Red Hat, Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. from unittest import mock from keystoneauth1.exceptions.auth_plugins import MissingAuthPlugin from keystoneauth1 import session import testtools from observabilityclient.v1 import rbac class RbacTest(testtools.TestCase): def setUp(self): super(RbacTest, self).setUp() self.rbac = rbac.Rbac(mock.Mock(), mock.Mock()) self.rbac.project_id = "secret_id" self.rbac.default_labels = { "project": self.rbac.project_id } def test_constructor(self): with mock.patch.object(session.Session, 'get_project_id', return_value="123"): r = rbac.Rbac("client", session.Session(), False) self.assertEqual(r.project_id, "123") self.assertEqual(r.default_labels, { "project": "123" }) def test_constructor_error(self): with mock.patch.object(session.Session, 'get_project_id', side_effect=MissingAuthPlugin()): r = rbac.Rbac("client", session.Session(), False) self.assertIsNone(r.project_id) def test_enrich_query(self): test_cases = [ ( "test_query", f"test_query{{project='{self.rbac.project_id}'}}" ), ( "test_query{somelabel='value'}", (f"test_query{{somelabel='value', " f"project='{self.rbac.project_id}'}}") ), ( "test_query{somelabel='value', label2='value2'}", (f"test_query{{somelabel='value', label2='value2', " f"project='{self.rbac.project_id}'}}") ), ( "test_query{somelabel='unicode{}{'}", (f"test_query{{somelabel='unicode{{}}{{', " f"project='{self.rbac.project_id}'}}") ), ( "test_query{doesnt_match_regex!~'regex'}", (f"test_query{{doesnt_match_regex!~'regex', " f"project='{self.rbac.project_id}'}}") ), ( "delta(cpu_temp_celsius{host='zeus'}[2h]) - " "sum(http_requests) + " "sum(http_requests{instance=~'.*'}) + " "sum(http_requests{or_regex=~'smth1|something2|3'})", (f"delta(cpu_temp_celsius{{host='zeus', " f"project='{self.rbac.project_id}'}}[2h]) - " f"sum(http_requests" f"{{project='{self.rbac.project_id}'}}) + " f"sum(http_requests{{instance=~'.*', " f"project='{self.rbac.project_id}'}}) + " f"sum(http_requests{{or_regex=~'smth1|something2|3', " f"project='{self.rbac.project_id}'}})") ) ] self.rbac.client.query.list = lambda disable_rbac: ['test_query', 'cpu_temp_celsius', 'http_requests'] for query, expected in test_cases: ret = self.rbac.enrich_query(query) self.assertEqual(ret, expected) def test_enrich_query_disable(self): test_cases = [ ( "test_query", "test_query" ), ( "test_query{somelabel='value'}", "test_query{somelabel='value'}" ), ( "test_query{somelabel='value', label2='value2'}", "test_query{somelabel='value', label2='value2'}" ), ( "test_query{somelabel='unicode{}{'}", "test_query{somelabel='unicode{}{'}" ), ( "test_query{doesnt_match_regex!~'regex'}", "test_query{doesnt_match_regex!~'regex'}", ), ( "delta(cpu_temp_celsius{host='zeus'}[2h]) - " "sum(http_requests) + " "sum(http_requests{instance=~'.*'}) + " "sum(http_requests{or_regex=~'smth1|something2|3'})", "delta(cpu_temp_celsius{host='zeus'}[2h]) - " "sum(http_requests) + " "sum(http_requests{instance=~'.*'}) + " "sum(http_requests{or_regex=~'smth1|something2|3'})" ) ] self.rbac.client.query.list = lambda disable_rbac: ['test_query', 'cpu_temp_celsius', 'http_requests'] for query, expected in test_cases: ret = self.rbac.enrich_query(query, disable_rbac=True) self.assertEqual(ret, query) def test_append_rbac(self): query = "test_query" expected = f"{query}{{project='{self.rbac.project_id}'}}" ret = self.rbac.append_rbac(query) self.assertEqual(ret, expected) def test_append_rbac_disable(self): query = "test_query" expected = query ret = self.rbac.append_rbac(query, disable_rbac=True) self.assertEqual(ret, expected) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/observabilityclient/tests/unit/test_utils.py0000664000175000017500000001633300000000000030171 0ustar00zuulzuul00000000000000# Copyright 2023 Red Hat, Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. import os from unittest import mock import testtools from observabilityclient import prometheus_client from observabilityclient.utils import metric_utils class GetConfigFileTest(testtools.TestCase): def setUp(self): super(GetConfigFileTest, self).setUp() def test_current_dir(self): with mock.patch.object(os.path, 'exists', return_value=True), \ mock.patch.object(metric_utils, 'open') as m: metric_utils.get_config_file() m.assert_called_with(metric_utils.CONFIG_FILE_NAME, 'r') def test_path_order(self): expected = [mock.call(metric_utils.CONFIG_FILE_NAME, 'r'), mock.call((f"{os.environ['HOME']}/.config/openstack/" f"{metric_utils.CONFIG_FILE_NAME}")), mock.call((f"/etc/openstack/" f"{metric_utils.CONFIG_FILE_NAME}"))] with mock.patch.object(os.path, 'exists', return_value=False) as m: ret = metric_utils.get_config_file() m.call_args_list == expected self.assertIsNone(ret) class GetPrometheusClientTest(testtools.TestCase): def setUp(self): super(GetPrometheusClientTest, self).setUp() config_data = 'host: "somehost"\nport: "1234"' self.config_file = mock.mock_open(read_data=config_data)("name", 'r') def test_get_prometheus_client_from_file(self): with mock.patch.object(metric_utils, 'get_config_file', return_value=self.config_file), \ mock.patch.object(prometheus_client.PrometheusAPIClient, "__init__", return_value=None) as m: metric_utils.get_prometheus_client() m.assert_called_with("somehost:1234") def test_get_prometheus_client_env_overide(self): with mock.patch.dict(os.environ, {'PROMETHEUS_HOST': 'env_overide'}), \ mock.patch.object(metric_utils, 'get_config_file', return_value=self.config_file), \ mock.patch.object(prometheus_client.PrometheusAPIClient, "__init__", return_value=None) as m: metric_utils.get_prometheus_client() m.assert_called_with("env_overide:1234") def test_get_prometheus_client_no_config_file(self): patched_env = {'PROMETHEUS_HOST': 'env_overide', 'PROMETHEUS_PORT': 'env_port'} with mock.patch.dict(os.environ, patched_env), \ mock.patch.object(metric_utils, 'get_config_file', return_value=None), \ mock.patch.object(prometheus_client.PrometheusAPIClient, "__init__", return_value=None) as m: metric_utils.get_prometheus_client() m.assert_called_with("env_overide:env_port") def test_get_prometheus_client_missing_configuration(self): with mock.patch.dict(os.environ, {}), \ mock.patch.object(metric_utils, 'get_config_file', return_value=None), \ mock.patch.object(prometheus_client.PrometheusAPIClient, "__init__", return_value=None): self.assertRaises(metric_utils.ConfigurationError, metric_utils.get_prometheus_client) class FormatLabelsTest(testtools.TestCase): def setUp(self): super(FormatLabelsTest, self).setUp() def test_format_labels_with_normal_labels(self): input_dict = {"label_key1": "label_value1", "label_key2": "label_value2"} expected = "label_key1='label_value1', label_key2='label_value2'" ret = metric_utils.format_labels(input_dict) self.assertEqual(expected, ret) def test_format_labels_with_quoted_labels(self): input_dict = {"label_key1": "'label_value1'", "label_key2": "'label_value2'"} expected = "label_key1='label_value1', label_key2='label_value2'" ret = metric_utils.format_labels(input_dict) self.assertEqual(expected, ret) class Metrics2ColsTest(testtools.TestCase): def setUp(self): super(Metrics2ColsTest, self).setUp() def test_metrics2cols(self): metric = { 'value': [ 1234567, 5 ], 'metric': { 'label1': 'value1', 'label2': 'value2', } } input_metrics = [prometheus_client.PrometheusMetric(metric)] expected = (['label1', 'label2', 'value'], [['value1', 'value2', 5]]) ret = metric_utils.metrics2cols(input_metrics) self.assertEqual(expected, ret) def test_metrics2cols_column_ordering(self): metric = { 'value': [ 1234567, 5 ], 'metric': { 'a_label1': 'value1', 'b_label2': 'value2', } } input_metrics = [prometheus_client.PrometheusMetric(metric)] expected = (['a_label1', 'b_label2', 'value'], [['value1', 'value2', 5]]) ret = metric_utils.metrics2cols(input_metrics) self.assertEqual(expected, ret) metric = { 'value': [ 1234567, 5 ], 'metric': { 'b_label1': 'value1', 'a_label2': 'value2', } } input_metrics = [prometheus_client.PrometheusMetric(metric)] expected = (['a_label2', 'b_label1', 'value'], [['value2', 'value1', 5]]) ret = metric_utils.metrics2cols(input_metrics) self.assertEqual(expected, ret) metric1 = { 'value': [ 1234567, 5 ], 'metric': { 'b_label1': 'value1', 'a_label2': 'value2', } } metric2 = { 'value': [ 1234567, 5 ], 'metric': { 'b_label1': 'value1', 'a_label2': 'value2', 'd_label3': 'value3', 'c_label4': 'value4', } } input_metrics = [prometheus_client.PrometheusMetric(metric1), prometheus_client.PrometheusMetric(metric2)] expected = (['a_label2', 'b_label1', 'c_label4', 'd_label3', 'value'], [['value2', 'value1', '', '', 5], ['value2', 'value1', 'value4', 'value3', 5]] ) ret = metric_utils.metrics2cols(input_metrics) self.assertEqual(expected, ret) ././@PaxHeader0000000000000000000000000000003300000000000011451 xustar000000000000000027 mtime=1740768577.859259 python_observabilityclient-0.4.0/observabilityclient/utils/0000775000175000017500000000000000000000000024431 5ustar00zuulzuul00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/observabilityclient/utils/__init__.py0000664000175000017500000000000000000000000026530 0ustar00zuulzuul00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/observabilityclient/utils/metric_utils.py0000664000175000017500000000654100000000000027514 0ustar00zuulzuul00000000000000# 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("Using %s as prometheus configuration", CONFIG_FILE_NAME) return open(CONFIG_FILE_NAME, "r") for path in DEFAULT_CONFIG_LOCATIONS: full_filename = path + CONFIG_FILE_NAME if os.path.exists(full_filename): LOG.debug("Using %s as prometheus configuration", full_filename) return open(full_filename, "r") return None def get_prometheus_client(): host = None port = None ca_cert = 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'] if 'ca_cert' in conf: ca_cert = conf['ca_cert'] 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 'PROMETHEUS_CA_CERT' in os.environ: ca_cert = os.environ['PROMETHEUS_CA_CERT'] if host is None or port is None: raise ConfigurationError("Can't find prometheus host and " "port configuration.") client = PrometheusAPIClient(f"{host}:{port}") if ca_cert is not None: client.set_ca_cert(ca_cert) return client def get_client(obj): return obj.app.client_manager.observabilityclient 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): # get all label keys cols = list(set().union(*(d.labels.keys() for d in m))) cols.sort() cols.append("value") fields = [] for metric in m: row = [""] * len(cols) for key, value in metric.labels.items(): row[cols.index(key)] = value row[cols.index("value")] = metric.value fields.append(row) return cols, fields ././@PaxHeader0000000000000000000000000000003300000000000011451 xustar000000000000000027 mtime=1740768577.859259 python_observabilityclient-0.4.0/observabilityclient/v1/0000775000175000017500000000000000000000000023617 5ustar00zuulzuul00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/observabilityclient/v1/__init__.py0000664000175000017500000000000000000000000025716 0ustar00zuulzuul00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/observabilityclient/v1/base.py0000664000175000017500000000432400000000000025106 0ustar00zuulzuul00000000000000# 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. # from osc_lib.command import command from observabilityclient.i18n import _ class ObservabilityBaseCommand(command.Command): """Base class for metric commands.""" def get_parser(self, prog_name): parser = super().get_parser(prog_name) # TODO(jwysogla): Should this be restricted somehow? parser.add_argument( '--disable-rbac', action='store_true', help=_("Disable rbac injection") ) return parser 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) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/observabilityclient/v1/cli.py0000664000175000017500000000767400000000000024756 0ustar00zuulzuul00000000000000# 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 cliff import lister from observabilityclient.i18n import _ from observabilityclient.utils import metric_utils from observabilityclient.v1 import base 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) ret = metric_utils.metrics2cols(metric) return ret 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]] ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/observabilityclient/v1/client.py0000664000175000017500000000315300000000000025451 0ustar00zuulzuul00000000000000# 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) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/observabilityclient/v1/python_api.py0000664000175000017500000000751400000000000026352 0ustar00zuulzuul00000000000000# 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): """List metric names. :param disable_rbac: Disables rbac injection if set to True :type disable_rbac: boolean """ if disable_rbac or self.client.rbac.disable_rbac: metric_names = self.prom.label_values("__name__") return sorted(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): """Show 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): """Send a query to prometheus. The query can be any PromQL query. Labels for enforcing rbac will be added to all of the metric name inside the query. 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=disable_rbac) return self.prom.query(query) def delete(self, matches, start=None, end=None): """Delete metrics from Prometheus. The metrics aren't deleted immediately. Do a call to clean_tombstones() to speed up the deletion. If start and end isn't specified, then 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): """Instruct prometheus to clean tombstones.""" return self.prom.clean_tombstones() def snapshot(self): """Create a snapshot of the current data.""" return self.prom.snapshot() ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/observabilityclient/v1/rbac.py0000664000175000017500000001374300000000000025110 0ustar00zuulzuul00000000000000# 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 re from keystoneauth1.exceptions.auth_plugins import MissingAuthPlugin from observabilityclient.utils.metric_utils import format_labels class ObservabilityRbacError(Exception): pass class Rbac(object): 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_match_operator(self, query, start): eq_sign_pos = query.find('=', start) tilde_pos = query.find('~', start) if eq_sign_pos == -1: return tilde_pos if tilde_pos == -1: return eq_sign_pos return min(eq_sign_pos, tilde_pos) def _find_label_pair_end(self, query, start): match_operator_pos = self._find_match_operator(query, start) quote_char = "'" quote_start_pos = query.find(quote_char, match_operator_pos) if quote_start_pos == -1: quote_char = '"' quote_start_pos = query.find(quote_char, match_operator_pos) end = self._find_label_value_end(query, quote_start_pos, quote_char) # returns the pair end or -1 return end 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_match_operator_pos = self._find_match_operator(query, pair_end) if (nearest_curly_brace_pos < nearest_match_operator_pos or nearest_match_operator_pos == -1): # If we have "}" before the nearest "=" or "~", # then we must be at the end of the label section # and the "=" or "~" is a part of the next section. return nearest_curly_brace_pos start = pair_end return -1 def enrich_query(self, query, disable_rbac=False): """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): """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)}}}" ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/pyproject.toml0000664000175000017500000000012500000000000022126 0ustar00zuulzuul00000000000000[build-system] requires = ['setuptools>=68'] build-backend = 'setuptools.build_meta' ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1740768577.8672588 python_observabilityclient-0.4.0/python_observabilityclient.egg-info/0000775000175000017500000000000000000000000026364 5ustar00zuulzuul00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768577.0 python_observabilityclient-0.4.0/python_observabilityclient.egg-info/PKG-INFO0000644000175000017500000000563400000000000027467 0ustar00zuulzuul00000000000000Metadata-Version: 2.1 Name: python-observabilityclient Version: 0.4.0 Summary: OpenStack Observability Client Home-page: https://infrawatch.github.io/documentation/ Author: OpenStack Author-email: openstack-discuss@lists.openstack.org License: Apache License, Version 2.0 Classifier: Environment :: Console Classifier: Environment :: OpenStack Classifier: Intended Audience :: Developers Classifier: Intended Audience :: Information Technology Classifier: Intended Audience :: System Administrators Classifier: License :: OSI Approved :: Apache Software License Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: 3 :: Only Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Requires-Python: >=3.9 Description-Content-Type: text/markdown; charset=UTF-8 License-File: LICENSE Requires-Dist: osc-lib>=1.0.1 Requires-Dist: keystoneauth1>=1.0.0 Requires-Dist: cliff>=1.14.0 Requires-Dist: PyYAML>5.1 # python-observabilityclient observabilityclient is an OpenStackClient (OSC) plugin implementation that implements commands for management of Prometheus. ## Development Install your OpenStack environment and patch your `openstack` client application using python. ``` # if using standalone, the following commands come after 'sudo dnf install -y python3-tripleoclient' su - stack # clone and install observability client plugin git clone https://github.com/infrawatch/python-observabilityclient cd python-observabilityclient sudo python setup.py install --prefix=/usr ``` ## Usage Use `openstack metric query somequery` to query for metrics in prometheus. To use the python api do the following: ``` from observabilityclient import client 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") ``` ## List of commands 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 ## 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 ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768577.0 python_observabilityclient-0.4.0/python_observabilityclient.egg-info/SOURCES.txt0000664000175000017500000000323700000000000030255 0ustar00zuulzuul00000000000000.zuul.yaml AUTHORS ChangeLog LICENSE README.md pyproject.toml requirements.txt setup.cfg setup.py test-requirements.txt tox.ini doc/requirements.txt observabilityclient/__init__.py observabilityclient/client.py observabilityclient/i18n.py observabilityclient/plugin.py observabilityclient/prometheus_client.py observabilityclient/tests/functional/__init__.py observabilityclient/tests/functional/base.py observabilityclient/tests/functional/test_cli.py observabilityclient/tests/functional/test_python_api.py observabilityclient/tests/unit/__init__.py observabilityclient/tests/unit/test_cli.py observabilityclient/tests/unit/test_prometheus_client.py observabilityclient/tests/unit/test_python_api.py observabilityclient/tests/unit/test_rbac.py observabilityclient/tests/unit/test_utils.py observabilityclient/utils/__init__.py observabilityclient/utils/metric_utils.py observabilityclient/v1/__init__.py observabilityclient/v1/base.py observabilityclient/v1/cli.py observabilityclient/v1/client.py observabilityclient/v1/python_api.py observabilityclient/v1/rbac.py python_observabilityclient.egg-info/PKG-INFO python_observabilityclient.egg-info/SOURCES.txt python_observabilityclient.egg-info/dependency_links.txt python_observabilityclient.egg-info/entry_points.txt python_observabilityclient.egg-info/not-zip-safe python_observabilityclient.egg-info/pbr.json python_observabilityclient.egg-info/requires.txt python_observabilityclient.egg-info/top_level.txt releasenotes/notes/remove-py38-acd461c4350f0dca.yaml releasenotes/source/conf.py releasenotes/source/index.rst releasenotes/source/unreleased.rst releasenotes/source/_static/.placeholder tools/fix_ca_bundle.sh tools/install_deps.sh././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768577.0 python_observabilityclient-0.4.0/python_observabilityclient.egg-info/dependency_links.txt0000664000175000017500000000000100000000000032432 0ustar00zuulzuul00000000000000 ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768577.0 python_observabilityclient-0.4.0/python_observabilityclient.egg-info/entry_points.txt0000664000175000017500000000065000000000000031663 0ustar00zuulzuul00000000000000[openstack.cli.extension] observabilityclient = observabilityclient.plugin [openstack.observabilityclient.v1] metric_clean-tombstones = observabilityclient.v1.cli:CleanTombstones metric_delete = observabilityclient.v1.cli:Delete metric_list = observabilityclient.v1.cli:List metric_query = observabilityclient.v1.cli:Query metric_show = observabilityclient.v1.cli:Show metric_snapshot = observabilityclient.v1.cli:Snapshot ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768577.0 python_observabilityclient-0.4.0/python_observabilityclient.egg-info/not-zip-safe0000664000175000017500000000000100000000000030612 0ustar00zuulzuul00000000000000 ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768577.0 python_observabilityclient-0.4.0/python_observabilityclient.egg-info/pbr.json0000664000175000017500000000005600000000000030043 0ustar00zuulzuul00000000000000{"git_version": "df6c253", "is_release": true}././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768577.0 python_observabilityclient-0.4.0/python_observabilityclient.egg-info/requires.txt0000664000175000017500000000007500000000000030766 0ustar00zuulzuul00000000000000osc-lib>=1.0.1 keystoneauth1>=1.0.0 cliff>=1.14.0 PyYAML>5.1 ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768577.0 python_observabilityclient-0.4.0/python_observabilityclient.egg-info/top_level.txt0000664000175000017500000000002400000000000031112 0ustar00zuulzuul00000000000000observabilityclient ././@PaxHeader0000000000000000000000000000003300000000000011451 xustar000000000000000027 mtime=1740768577.851259 python_observabilityclient-0.4.0/releasenotes/0000775000175000017500000000000000000000000021705 5ustar00zuulzuul00000000000000././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1740768577.8632588 python_observabilityclient-0.4.0/releasenotes/notes/0000775000175000017500000000000000000000000023035 5ustar00zuulzuul00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/releasenotes/notes/remove-py38-acd461c4350f0dca.yaml0000664000175000017500000000016600000000000030357 0ustar00zuulzuul00000000000000--- upgrade: - | Support for Python 3.8 has been removed. Now the minimum python version supported is 3.9 . ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1740768577.8632588 python_observabilityclient-0.4.0/releasenotes/source/0000775000175000017500000000000000000000000023205 5ustar00zuulzuul00000000000000././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1740768577.8632588 python_observabilityclient-0.4.0/releasenotes/source/_static/0000775000175000017500000000000000000000000024633 5ustar00zuulzuul00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/releasenotes/source/_static/.placeholder0000664000175000017500000000000000000000000027104 0ustar00zuulzuul00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/releasenotes/source/conf.py0000664000175000017500000002047500000000000024514 0ustar00zuulzuul00000000000000# 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. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # sys.path.insert(0, os.path.abspath('.')) # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'openstackdocstheme', 'reno.sphinxext', ] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix of source filenames. source_suffix = '.rst' # The encoding of source files. # source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # openstackdocstheme options openstackdocs_repo_name = 'openstack/python-observability' openstackdocs_bug_project = 'python-observability' openstackdocs_bug_tag = '' copyright = '2022-present, Telemetry developers' # Release notes are version independent. # The short X.Y version. version = '' # The full version, including alpha/beta/rc tags. release = '' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: # today = '' # Else, today_fmt is used as the format for a strftime call. # today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = [] # The reST default role (used for this markup: `text`) to use for all # documents. # default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. # add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). # add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. # show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'native' # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. # keep_warnings = False # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = 'openstackdocs' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". # html_title = None # A shorter title for the navigation bar. Default is the same as html_title. # html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. # html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. # html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. # html_extra_path = [] # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. # html_use_smartypants = True # Custom sidebar templates, maps document names to template names. # html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. # html_additional_pages = {} # If false, no module index is generated. # html_domain_indices = True # If false, no index is generated. # html_use_index = True # If true, the index is split into individual pages for each letter. # html_split_index = False # If true, links to the reST sources are added to the pages. # html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. # html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. # html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. # html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). # html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = 'observabilityReleaseNotestdoc' # -- Options for LaTeX output --------------------------------------------- # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ ('index', 'Pythonobservability.tex', 'Observability Client Release Notes Documentation', 'Telemetry developers', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. # latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. # latex_use_parts = False # If true, show page references after internal links. # latex_show_pagerefs = False # If true, show URL addresses after external links. # latex_show_urls = False # Documents to append as an appendix to all manuals. # latex_appendices = [] # If false, no module index is generated. # latex_domain_indices = True # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ('index', 'pythonobservability', 'Observability Client Release Notes Documentation', ['Telemetry developers'], 1) ] # If true, show URL addresses after external links. # man_show_urls = False # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ('index', 'Pythonobservability', 'Observability Client Release Notes Documentation', 'Telemetry developers', 'Pythonobservability', 'One line description of project.', 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. # texinfo_appendices = [] # If false, no module index is generated. # texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. # texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. # texinfo_no_detailmenu = False # -- Options for Internationalization output ------------------------------ locale_dirs = ['locale/'] ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/releasenotes/source/index.rst0000664000175000017500000000040400000000000025044 0ustar00zuulzuul00000000000000Welcome to Observability Client Release Notes documentation! ============================================================ Contents ======== .. toctree:: :maxdepth: 2 unreleased Indices and tables ================== * :ref:`genindex` * :ref:`search` ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/releasenotes/source/unreleased.rst0000664000175000017500000000015300000000000026065 0ustar00zuulzuul00000000000000============================ Current Series Release Notes ============================ .. release-notes:: ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/requirements.txt0000664000175000017500000000016100000000000022476 0ustar00zuulzuul00000000000000osc-lib>=1.0.1 # Apache-2.0 keystoneauth1>=1.0.0 # Apache-2.0 cliff>=1.14.0 # Apache-2.0 PyYAML>5.1 # Apache-2.0 ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1740768577.8672588 python_observabilityclient-0.4.0/setup.cfg0000664000175000017500000000305700000000000021042 0ustar00zuulzuul00000000000000[metadata] name = python-observabilityclient summary = OpenStack Observability Client long_description = file: README.md long_description_content_type = text/markdown; charset=UTF-8 license = Apache License, Version 2.0 author = OpenStack author_email = openstack-discuss@lists.openstack.org home_page = https://infrawatch.github.io/documentation/ python_requires = >=3.9 classifier = Environment :: Console Environment :: OpenStack Intended Audience :: Developers Intended Audience :: Information Technology Intended Audience :: System Administrators License :: OSI Approved :: Apache Software License Operating System :: OS Independent Programming Language :: Python Programming Language :: Python :: Implementation :: CPython Programming Language :: Python :: 3 :: Only Programming Language :: Python :: 3 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 [files] packages = observabilityclient [entry_points] openstack.cli.extension = observabilityclient = observabilityclient.plugin openstack.observabilityclient.v1 = 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] show-source = True builtins = _ exclude = .venv, .git, dist [egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/setup.py0000664000175000017500000000127100000000000020727 0ustar00zuulzuul00000000000000# Copyright (c) 2013 Hewlett-Packard Development Company, L.P. # # 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 setuptools setuptools.setup( setup_requires=['pbr>=2.0.0'], pbr=True) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/test-requirements.txt0000664000175000017500000000023400000000000023454 0ustar00zuulzuul00000000000000python-openstackclient>=6.3.0 # Apache-2.0 os-client-config>=1.28.0 # Apache-2.0 stestr>=2.0.0 # Apache-2.0 tempest>=10 # Apache-2.0 testtools>=1.4.0 # MIT ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1740768577.8672588 python_observabilityclient-0.4.0/tools/0000775000175000017500000000000000000000000020354 5ustar00zuulzuul00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/tools/fix_ca_bundle.sh0000664000175000017500000000277100000000000023501 0ustar00zuulzuul00000000000000# File taken from python-glanceclient # When the functional tests are run in a devstack environment, we # need to make sure that the python-requests module installed by # tox in the test environment can find the distro-specific CA store # where the devstack certs have been installed. # # assumptions: # - devstack is running # - the devstack tls-proxy service is running # - the environment var OS_TESTENV_NAME is set in tox.ini (defaults # to 'functional' # # This code based on a function in devstack lib/tls function set_ca_bundle { local python_cmd=".tox/${OS_TESTENV_NAME:-functional}/bin/python" local capath=$($python_cmd -c $'try:\n from requests import certs\n print (certs.where())\nexcept ImportError: pass') # of course, each distro keeps the CA store in a different location local fedora_CA='/etc/pki/tls/certs/ca-bundle.crt' local ubuntu_CA='/etc/ssl/certs/ca-certificates.crt' local suse_CA='/etc/ssl/ca-bundle.pem' # the distro CA is rooted in /etc, so if ours isn't, we need to # change it if [[ ! $capath == "" && ! $capath =~ ^/etc/.* && ! -L $capath ]]; then if [[ -e $fedora_CA ]]; then rm -f $capath ln -s $fedora_CA $capath elif [[ -e $ubuntu_CA ]]; then rm -f $capath ln -s $ubuntu_CA $capath elif [[ -e $suse_CA ]]; then rm -f $capath ln -s $suse_CA $capath else echo "can't set CA bundle, expect tests to fail" fi fi } set_ca_bundle ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/tools/install_deps.sh0000775000175000017500000000112100000000000023367 0ustar00zuulzuul00000000000000#!/bin/bash -ex sudo apt-get update -y && sudo apt-get install -qy gnupg software-properties-common sudo add-apt-repository -y ppa:deadsnakes/ppa sudo apt-get update -y && sudo apt-get install -qy \ locales \ git \ wget \ curl \ python3 \ python3-dev \ python3-pip \ python3.9 \ python3.9-dev \ python3.9-distutils \ python3.11 \ python3.11-dev sudo rm -rf /var/lib/apt/lists/* export LANG=en_US.UTF-8 sudo update-locale sudo locale-gen $LANG sudo python3 -m pip install -U pip tox virtualenv ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1740768529.0 python_observabilityclient-0.4.0/tox.ini0000664000175000017500000000552200000000000020533 0ustar00zuulzuul00000000000000[tox] minversion = 4.2.5 envlist = py38,py39,py311,pep8 ignore_basepython_conflict = True [testenv] basepython = python3 usedevelop = True setenv = VIRTUAL_ENV={envdir} OBSERVABILITY_CLIENT_EXEC_DIR={envdir}/bin OS_TEST_PATH = ./observabilityclient/tests/unit passenv = PROMETHEUS_* OBSERVABILITY_* deps = -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master} -r{toxinidir}/requirements.txt -r{toxinidir}/test-requirements.txt commands = stestr run --slowest {posargs} --test-path {env:OS_TEST_PATH} [testenv:pep8] basepython = python3 deps = hacking<3.1.0,>=3.0 commands = flake8 [testenv:venv] commands = {posargs} [testenv:cover] deps = {[testenv]deps} pytest-cov commands = observabilityclient {posargs} {env:$OS_TEST_PATH} [testenv:functional] setenv = OS_TEST_PATH = ./observabilityclient/tests/functional OS_TESTENV_NAME = {envname} allowlist_externals = bash deps = {[testenv]deps} pytest commands = bash tools/fix_ca_bundle.sh stestr run --slowest {posargs} --test-path {env:OS_TEST_PATH} [flake8] show-source = True # A002 argument "input" is shadowing a python builtin # A003 class attribute "list" is shadowing a python builtin # D100 Missing docstring in public module # D101 Missing docstring in public class # D102 Missing docstring in public method # D103 Missing docstring in public function # D104 Missing docstring in public package # D105 Missing docstring in magic method # D106 Missing docstring in public nested class # D107 Missing docstring in __init__ # W503 line break before binary operator # W504 line break after binary operator ignore = A002,A003,D100,D101,D102,D103,D104,D105,D106,D107,W503,W504 exclude=.venv,.git,.tox,dist,doc,*egg,build # [H101] Include your name with TODOs as in # TODO(yourname). # [H104] Empty files should not contain license or comments # [H106] Do not put vim configuration in source files. # [H201] Do not write except:, use except Exception: at the very least. # [H202] Testing for Exception being raised # [H203] Use assertIs(Not)None to check for None. # [H204] Use assert(Not)Equal to check for equality. # [H205] Use assert(Greater|Less)(Equal) for comparison. # [H23] Py3 compat # [H301] Do not import more than one module per line (*) # [H303] Do not use wildcard * import (*) # [H304] Do not make relative imports # [H306] Alphabetically order your imports by the full module path. enable-extensions=G,H101,H104,H106,H201,H202,H203,H204,H205,H23,H301,H303,H304,H306 application-import-names = observabilityclient [pytest] addopts = --verbose norecursedirs = .tox [testenv:releasenotes] deps = -r{toxinidir}/doc/requirements.txt -r{toxinidir}/requirements.txt allowlist_externals = sphinx-build commands = sphinx-build -a -E -W -d releasenotes/build/doctrees --keep-going -b html releasenotes/source releasenotes/build/html