From 80b604f1dfa004c8927a18ee7cfbdbc88e5f53b7 Mon Sep 17 00:00:00 2001 From: Daniel Salzman Date: Sat, 12 Aug 2023 21:31:25 +0200 Subject: [PATCH] python: add knot_exporter Based on https://github.com/salzmdan/knot_exporter, commit 564a6daa3e17f68e410d25fe4b876d79e418d4d1 --- configure.ac | 3 + python/Makefile.am | 3 +- python/knot_exporter/Makefile.am | 19 ++ python/knot_exporter/README.md | 20 ++ .../knot_exporter/knot_exporter/__init__.py | 0 .../knot_exporter/knot_exporter.py | 246 ++++++++++++++++++ python/knot_exporter/pyproject.toml.in | 46 ++++ python/knot_exporter/setup.py.in | 38 +++ 8 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 python/knot_exporter/Makefile.am create mode 100644 python/knot_exporter/README.md create mode 100644 python/knot_exporter/knot_exporter/__init__.py create mode 100755 python/knot_exporter/knot_exporter/knot_exporter.py create mode 100644 python/knot_exporter/pyproject.toml.in create mode 100644 python/knot_exporter/setup.py.in diff --git a/configure.ac b/configure.ac index 45ea5a5a8..dfad59b69 100644 --- a/configure.ac +++ b/configure.ac @@ -855,6 +855,9 @@ AC_CONFIG_FILES([Makefile samples/Makefile distro/Makefile python/Makefile + python/knot_exporter/Makefile + python/knot_exporter/pyproject.toml + python/knot_exporter/setup.py python/libknot/Makefile python/libknot/pyproject.toml python/libknot/setup.py diff --git a/python/Makefile.am b/python/Makefile.am index 8f3fcd97a..05c90172e 100644 --- a/python/Makefile.am +++ b/python/Makefile.am @@ -1,6 +1,7 @@ -SUBDIRS = libknot +SUBDIRS = knot_exporter libknot TARGETS = dist upload .PHONY: $(TARGETS) $(TARGETS): + $(MAKE) -C knot_exporter $@ $(MAKE) -C libknot $@ diff --git a/python/knot_exporter/Makefile.am b/python/knot_exporter/Makefile.am new file mode 100644 index 000000000..e234a08c7 --- /dev/null +++ b/python/knot_exporter/Makefile.am @@ -0,0 +1,19 @@ +EXTRA_DIST = \ + knot_exporter/__init__.py \ + knot_exporter/knot_exporter.py \ + pyproject.toml.in \ + setup.py.in \ + README.md + +clean-local: + -rm -rf dist *.egg-info + +dist: clean-local + @if hatchling -h &> /dev/null; then \ + hatchling build; \ + else \ + python3 setup.py sdist; \ + fi + +upload: + twine upload dist/* diff --git a/python/knot_exporter/README.md b/python/knot_exporter/README.md new file mode 100644 index 000000000..5b09c2d3c --- /dev/null +++ b/python/knot_exporter/README.md @@ -0,0 +1,20 @@ +# knot-exporter + +A Prometheus exporter for [Knot DNS](https://www.knot-dns.cz/)'s server and query statistics. + +# Getting Started + +The Knot instance also needs to be configured to collect statistics using +[Statistics module](https://www.knot-dns.cz/docs/latest/html/modules.html?highlight=mod%20stats#stats-query-statistics) + +The exporter can be started via: + +```bash +$ knot-exporter +``` + +To get a complete list of the available options, run: + +```bash +$ knot-exporter --help +``` diff --git a/python/knot_exporter/knot_exporter/__init__.py b/python/knot_exporter/knot_exporter/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/knot_exporter/knot_exporter/knot_exporter.py b/python/knot_exporter/knot_exporter/knot_exporter.py new file mode 100755 index 000000000..be3bf0367 --- /dev/null +++ b/python/knot_exporter/knot_exporter/knot_exporter.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 + +import argparse +import http.server +import ipaddress +import psutil +import re +import socket +import subprocess + +import libknot +import libknot.control + +from prometheus_client.core import REGISTRY +from prometheus_client.core import GaugeMetricFamily +from prometheus_client.exposition import MetricsHandler + + +def memory_usage(): + out = dict() + pids = subprocess.check_output(['pidof', 'knotd']).split(b' ') + for pid in pids: + if not pid: + continue + key = int(pid) + out[key] = psutil.Process(key).memory_info()._asdict()['rss'] + return out + + +class KnotCollector(object): + def __init__(self, lib, sock, ttl, + collect_meminfo : bool, + collect_stats : bool, + collect_zone_stats : bool, + collect_zone_status : bool, + collect_zone_timers : bool,): + libknot.Knot(lib) + self._sock = sock + self._ttl = ttl + self.collect_meminfo = collect_meminfo + self.collect_stats = collect_stats + self.collect_zone_stats = collect_zone_stats + self.collect_zone_status = collect_zone_status + self.collect_zone_timers = collect_zone_timers + + def convert_state_time(time): + if time == "pending" or time == "running" or time == "frozen": + return 0 + elif time == "not scheduled" or time == "-": + return None + else: + match = re.match("([+-])((\d+)D)?((\d+)h)?((\d+)m)?((\d+)s)?", time) + seconds = -1 if match.group(1) == '-' else 1 + if match.group(3): + seconds = seconds + 86400 * int(match.group(3)) + if match.group(5): + seconds = seconds + 3600 * int(match.group(5)) + if match.group(7): + seconds = seconds + 60 * int(match.group(7)) + if match.group(9): + seconds = seconds + int(match.group(9)) + + return seconds + + def collect(self): + ctl = libknot.control.KnotCtl() + ctl.connect(self._sock) + ctl.set_timeout(self._ttl) + metric_families = dict() + + def metric_families_append(family, labels, labels_val, data): + m = metric_families.get(family, GaugeMetricFamily(family, '', labels=labels)) + m.add_metric(labels_val, data) + metric_families[family] = m + + if self.collect_meminfo: + # Get global metrics. + for pid, usage in memory_usage().items(): + metric_families_append('knot_memory_usage', ['section', 'type'], ['server', str(pid)], usage) + + if self.collect_stats: + ctl.send_block(cmd="stats", flags="") + global_stats = ctl.receive_stats() + + for section, section_data in global_stats.items(): + for item, item_data in section_data.items(): + name = ('knot_' + item).replace('-', '_') + try: + for kind, kind_data in item_data.items(): + metric_families_append(name, ['section', 'type'], [section, kind], kind_data) + + except AttributeError: + metric_families_append(name, ['section'], [section], item_data) + + if self.collect_zone_stats: + # Get zone metrics. + ctl.send_block(cmd="zone-stats", flags="") + zone_stats = ctl.receive_stats() + + if "zone" in zone_stats: + for zone, zone_data in zone_stats["zone"].items(): + for section, section_data in zone_data.items(): + for item, item_data in section_data.items(): + name = ('knot_' + item).replace('-', '_') + try: + for kind, kind_data in item_data.items(): + metric_families_append(name, ['zone', 'section', 'type'], [zone, section, kind], kind_data) + except AttributeError: + metric_families_append(name, ['zone', 'section'], [zone, section], item_data) + + if self.collect_zone_status: + # zone state metrics + ctl.send_block(cmd="zone-status") + zone_states = ctl.receive_block() + + for zone, info in zone_states.items(): + serial = info.get('serial', False) + if serial and serial != "none" and serial != "-": + metric_families_append('knot_zone_serial', ['zone'], [zone], int(serial)) + + metrics = ['expiration', 'refresh'] + + for metric in metrics: + seconds = KnotCollector.convert_state_time(info[metric]) + if seconds == None: + continue + + metric_families_append('knot_zone_stats_' + metric, ['zone'], [zone], seconds) + + if self.collect_zone_timers: + # zone configuration metrics + ctl.send_block(cmd="zone-read", rtype="SOA") + zones = ctl.receive_block() + + for name, params in zones.items(): + metrics = [ + {"name": "knot_zone_refresh", "index": 3}, + {"name": "knot_zone_retry", "index": 4}, + {"name": "knot_zone_expiration", "index": 5}, + ] + + zone_config = params[name]['SOA']['data'][0].split(" ") + + for metric in metrics: + metric_families_append(metric['name'], ['zone'], [name], int(zone_config[metric['index']])) + + for val in metric_families.values(): + yield val + + +def main(): + parser = argparse.ArgumentParser( + formatter_class = argparse.ArgumentDefaultsHelpFormatter, + ) + + parser.add_argument( + "--web-listen-addr", + default="127.0.0.1", + help="address on which to expose metrics." + ) + + parser.add_argument( + "--web-listen-port", + type=int, + default=9433, + help="port on which to expose metrics." + ) + + parser.add_argument( + "--knot-library-path", + default=None, + help="path to libknot." + ) + + parser.add_argument( + "--knot-socket-path", + default="/run/knot/knot.sock", + help="path to knot control socket." + ) + + parser.add_argument( + "--knot-socket-timeout", + type=int, + default=2000, + help="timeout for Knot control socket operations." + ) + + parser.add_argument( + "--no-meminfo", + action='store_false', + help="disable collection of memory usage" + ) + + parser.add_argument( + "--no-global-stats", + action='store_false', + help="disable collection of global statistics" + ) + + parser.add_argument( + "--no-zone-stats", + action='store_false', + help="disable collection of zone statistics" + ) + + parser.add_argument( + "--no-zone-status", + action='store_false', + help="disable collection of zone status" + ) + + parser.add_argument( + "--no-zone-timers", + action='store_false', + help="disable collection of zone timer settings" + ) + + args = parser.parse_args() + + REGISTRY.register(KnotCollector( + args.knot_library_path, + args.knot_socket_path, + args.knot_socket_timeout, + args.no_meminfo, + args.no_global_stats, + args.no_zone_stats, + args.no_zone_status, + args.no_zone_timers, + )) + + class Server(http.server.HTTPServer): + def __init__(self, server_address, RequestHandlerClass): + ip = ipaddress.ip_address(server_address[0]) + self.address_family = socket.AF_INET6 if ip.version == 6 else socket.AF_INET + super().__init__(server_address, RequestHandlerClass) + + httpd = Server( + (args.web_listen_addr, args.web_listen_port), + MetricsHandler, + ) + + httpd.serve_forever() + + +if __name__ == '__main__': + main() diff --git a/python/knot_exporter/pyproject.toml.in b/python/knot_exporter/pyproject.toml.in new file mode 100644 index 000000000..ccda5d467 --- /dev/null +++ b/python/knot_exporter/pyproject.toml.in @@ -0,0 +1,46 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "knot_exporter" +version = "@PACKAGE_VERSION@" +description = "Prometheus exporter for Knot DNS" +readme = "README.md" +requires-python = ">=3.5" +license = { text = "GPL-3.0" } +authors = [ + { name = "CZ.NIC, z.s.p.o.", email = "knot-dns@labs.nic.cz" }, + { name = "Alessandro Ghedini", email = "alessandro@ghedini.me" }, +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Programming Language :: Python :: 3", + "Topic :: Internet :: Name Service (DNS)", + "Topic :: System :: Systems Administration", +] +dependencies = [ + "libknot", + "prometheus-client", + "psutil", +] + +[project.urls] +Documentation = "https://www.knot-dns.cz/documentation" +Issues = "https://gitlab.nic.cz/knot/knot-dns/-/issues" +Source = "https://gitlab.nic.cz/knot/knot-dns/python/knot_exporter" + +[project.scripts] +knot-exporter = "knot_exporter.knot_exporter:main" + +[tool.hatch.build.targets.sdist] +artifacts = [ + "knot_exporter/__init__.py", +] +exclude = [ + ".*", + "*.in", + "Makefile*", +] diff --git a/python/knot_exporter/setup.py.in b/python/knot_exporter/setup.py.in new file mode 100644 index 000000000..5cf843aa0 --- /dev/null +++ b/python/knot_exporter/setup.py.in @@ -0,0 +1,38 @@ +import pathlib +import setuptools + +p = pathlib.Path("README.md") +if p.exists(): + long_description = p.read_text() + +setuptools.setup( + name='knot_exporter', + version='@PACKAGE_VERSION@', + description='Prometheus exporter for Knot DNS', + long_description=long_description, + long_description_content_type="text/markdown", + author='CZ.NIC, z.s.p.o.', + author_email='knot-dns@labs.nic.cz', + url='https://gitlab.nic.cz/knot/knot-dns/python/knot_exporter', + license='GPL-3.0', + packages=['knot_exporter'], + classifiers=[ # See https://pypi.org/classifiers + 'Development Status :: 5 - Production/Stable', + 'Intended Audience :: System Administrators', + 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', + 'Programming Language :: Python :: 3', + 'Topic :: Internet :: Name Service (DNS)', + 'Topic :: System :: Systems Administration', + ], + python_requires='>=3.5', + install_requires=[ + 'libknot', + 'prometheus-client', + 'psutil', + ], + entry_points={ + 'console_scripts': [ + 'knot-exporter = knot_exporter.knot_exporter:main', + ], + }, +)