python: add knot_exporter

Based on https://github.com/salzmdan/knot_exporter,
commit 564a6daa3e17f68e410d25fe4b876d79e418d4d1
This commit is contained in:
Daniel Salzman 2023-08-12 21:31:25 +02:00
parent 4d7eb96bf9
commit 80b604f1df
8 changed files with 374 additions and 1 deletions

View file

@ -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

View file

@ -1,6 +1,7 @@
SUBDIRS = libknot
SUBDIRS = knot_exporter libknot
TARGETS = dist upload
.PHONY: $(TARGETS)
$(TARGETS):
$(MAKE) -C knot_exporter $@
$(MAKE) -C libknot $@

View file

@ -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/*

View file

@ -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
```

View file

@ -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()

View file

@ -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*",
]

View file

@ -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',
],
},
)