Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Unify CLI target specification #51

Merged
merged 4 commits into from
Mar 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 14 additions & 16 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -65,33 +65,31 @@ The following features are not supported yet, help is welcome.
Usage
-----

::
Pass one or multiple Unix Domain Socket path or HTTP Control-Agent URLs
to the `kea-exporter` executable. All other options are optional.

Usage: kea-exporter [OPTIONS] [SOCKETS]...
::

Options:
-m, --mode [socket|http] Select mode.
-a, --address TEXT Specify the address to bind against.
-p, --port INTEGER Specify the port on which to listen.
-i, --interval INTEGER Minimal interval between two queries to Kea in seconds.
-t, --target TEXT Target address and port of Kea server, e.g.
http://kea.example.com:8080.
--client-cert TEXT Client certificate file path used in HTTP mode
with mTLS
--client-key TEXT Client key file path used in HTTP mode with mTLS
--version Show the version and exit.
--help Show this message and exit.
Usage: python -m kea_exporter [OPTIONS] TARGETS...

Options:
-a, --address TEXT Address that the exporter binds to.
-p, --port INTEGER Port that the exporter binds to.
-i, --interval INTEGER Minimal interval between two queries to Kea in
seconds.
--client-cert PATH Path to client certificate used to in HTTP requests
--client-key PATH Path to client key used in HTTP requests
--version Show the version and exit.
--help Show this message and exit.

You can also configure the exporter using environment variables:

::

export MODE="http"
export ADDRESS="0.0.0.0"
export PORT="9547"
export INTERVAL="7.5"
export TARGET="http://kea"
export TARGETS="http://router.example.com:8000"
export CLIENT_CERT="/etc/kea-exporter/client.crt"
export CLIENT_KEY="/etc/kea-exporter/client.key"

Expand Down
2 changes: 1 addition & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
src = ./.;
hooks = {
isort.enable = true;
ruff.enable = false;
ruff.enable = true;
ruff-format = {
enable = true;
entry = "${pkgs.ruff}/bin/ruff format";
Expand Down
7 changes: 7 additions & 0 deletions kea_exporter/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,9 @@
from enum import Enum

__project__ = "kea-exporter"
__version__ = "0.6.2"


class DHCPVersion(Enum):
DHCP4 = 1
DHCP6 = 2
47 changes: 16 additions & 31 deletions kea_exporter/cli.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import sys
import time

import click
from prometheus_client import REGISTRY, make_wsgi_app, start_http_server

from . import __project__, __version__
from kea_exporter import __project__, __version__
from kea_exporter.exporter import Exporter


class Timer:
Expand All @@ -19,28 +21,21 @@ def time_elapsed(self):


@click.command()
@click.option(
"-m",
"--mode",
envvar="MODE",
default="socket",
help="Select mode.",
type=click.Choice(["socket", "http"], case_sensitive=True),
)
@click.option(
"-a",
"--address",
envvar="ADDRESS",
type=str,
default="0.0.0.0",
help="Specify the address to bind against.",
help="Address that the exporter binds to.",
)
@click.option(
"-p",
"--port",
envvar="PORT",
type=int,
default=9547,
help="Specify the port on which to listen.",
help="Port that the exporter binds to.",
)
@click.option(
"-i",
Expand All @@ -50,37 +45,27 @@ def time_elapsed(self):
default=0,
help="Minimal interval between two queries to Kea in seconds.",
)
@click.option(
"-t",
"--target",
envvar="TARGET",
type=str,
help="Target address and port of Kea server, e.g. http://kea.example.com:8080.",
)
@click.option(
"--client-cert",
envvar="CLIENT_CERT",
type=str,
help="Client certificate file path used in HTTP mode with mTLS",
type=click.Path(exists=True),
help="Path to client certificate used to in HTTP requests",
required=False,
)
@click.option(
"--client-key",
envvar="CLIENT_KEY",
type=str,
help="Client key file path used in HTTP mode with mTLS",
type=click.Path(exists=True),
help="Path to client key used in HTTP requests",
required=False,
)
@click.argument("sockets", envvar="SOCKETS", nargs=-1, required=False)
@click.argument("targets", envvar="TARGETS", nargs=-1, required=True)
@click.version_option(prog_name=__project__, version=__version__)
def cli(mode, port, address, interval, **kwargs):
if mode == "socket":
from .kea_socket_exporter import KeaSocketExporter as KeaExporter
elif mode == "http":
from .kea_http_exporter import KeaHTTPExporter as KeaExporter

exporter = KeaExporter(**kwargs)
exporter.update()
def cli(port, address, interval, **kwargs):
exporter = Exporter(**kwargs)

if not exporter.targets:
sys.exit(1)

httpd, _ = start_http_server(port, address)

Expand Down
51 changes: 37 additions & 14 deletions kea_exporter/base_exporter.py → kea_exporter/exporter.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import re
import sys
from enum import Enum
from urllib.parse import urlparse

import click
from prometheus_client import Gauge

from kea_exporter import DHCPVersion
from kea_exporter.http import KeaHTTPClient
from kea_exporter.uds import KeaSocketClient

class BaseExporter:
class DHCPVersion(Enum):
DHCP4 = 1
DHCP6 = 2

class Exporter:
subnet_pattern = re.compile(
r"^subnet\[(?P<subnet_id>[\d]+)\]\.(pool\[(?P<pool_index>[\d]+)\]\.(?P<pool_metric>[\w-]+)|(?P<subnet_metric>[\w-]+))$"
)

def __init__(self):
def __init__(self, targets, **kwargs):
# prometheus
self.prefix = "kea"
self.prefix_dhcp4 = f"{self.prefix}_dhcp4"
Expand All @@ -38,10 +38,33 @@ def __init__(self):

# track missing info, to notify only once
self.subnet_missing_info_sent = {
self.DHCPVersion.DHCP4: [],
self.DHCPVersion.DHCP6: [],
DHCPVersion.DHCP4: [],
DHCPVersion.DHCP6: [],
}

self.targets = []
for target in targets:
url = urlparse(target)
client = None
try:
if url.scheme:
client = KeaHTTPClient(target, **kwargs)
elif url.path:
client = KeaSocketClient(target, **kwargs)
else:
click.echo(f"Unable to parse target argument: {target}")
continue
except OSError as ex:
click.echo(ex)
continue

self.targets.append(client)

def update(self):
for target in self.targets:
for response in target.stats():
self.parse_metrics(*response)

def setup_dhcp4_metrics(self):
self.metrics_dhcp4 = {
# Packets
Expand Down Expand Up @@ -451,10 +474,10 @@ def setup_dhcp6_metrics(self):

def parse_metrics(self, dhcp_version, arguments, subnets):
for key, data in arguments.items():
if dhcp_version is self.DHCPVersion.DHCP4:
if dhcp_version is DHCPVersion.DHCP4:
if key in self.metrics_dhcp4_global_ignore:
continue
elif dhcp_version is self.DHCPVersion.DHCP6:
elif dhcp_version is DHCPVersion.DHCP6:
if key in self.metrics_dhcp6_global_ignore:
continue
else:
Expand All @@ -470,13 +493,13 @@ def parse_metrics(self, dhcp_version, arguments, subnets):
pool_metric = subnet_match.group("pool_metric")
subnet_metric = subnet_match.group("subnet_metric")

if dhcp_version is self.DHCPVersion.DHCP4:
if dhcp_version is DHCPVersion.DHCP4:
if (
pool_metric in self.metric_dhcp4_subnet_ignore
or subnet_metric in self.metric_dhcp4_subnet_ignore
):
continue
elif dhcp_version is self.DHCPVersion.DHCP6:
elif dhcp_version is DHCPVersion.DHCP6:
if (
pool_metric in self.metric_dhcp6_subnet_ignore
or subnet_metric in self.metric_dhcp6_subnet_ignore
Expand Down Expand Up @@ -521,10 +544,10 @@ def parse_metrics(self, dhcp_version, arguments, subnets):
key = subnet_metric
labels["pool"] = ""

if dhcp_version is self.DHCPVersion.DHCP4:
if dhcp_version is DHCPVersion.DHCP4:
metrics_map = self.metrics_dhcp4_map
metrics = self.metrics_dhcp4
elif dhcp_version is self.DHCPVersion.DHCP6:
elif dhcp_version is DHCPVersion.DHCP6:
metrics_map = self.metrics_dhcp6_map
metrics = self.metrics_dhcp6
else:
Expand Down
12 changes: 6 additions & 6 deletions kea_exporter/kea_http_exporter.py → kea_exporter/http.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import requests

from .base_exporter import BaseExporter
from kea_exporter import DHCPVersion


class KeaHTTPExporter(BaseExporter):
class KeaHTTPClient:
def __init__(self, target, client_cert, client_key, **kwargs):
super().__init__()

Expand Down Expand Up @@ -49,7 +49,7 @@ def load_subnets(self):
for subnet in module.get("arguments", {}).get("Dhcp6", {}).get("subnet6", {}):
self.subnets6.update({subnet["id"]: subnet})

def update(self):
def stats(self):
# Reload subnets on update in case of configurational update
self.load_subnets()
# Note for future testing: pipe curl output to jq for an easier read
Expand All @@ -67,14 +67,14 @@ def update(self):

for index, module in enumerate(self.modules):
if module == "dhcp4":
dhcp_version = self.DHCPVersion.DHCP4
dhcp_version = DHCPVersion.DHCP4
subnets = self.subnets
elif module == "dhcp6":
dhcp_version = self.DHCPVersion.DHCP6
dhcp_version = DHCPVersion.DHCP6
subnets = self.subnets6
else:
continue

arguments = response[index].get("arguments", {})

self.parse_metrics(dhcp_version, arguments, subnets)
yield dhcp_version, arguments, subnets
53 changes: 18 additions & 35 deletions kea_exporter/kea_socket_exporter.py → kea_exporter/uds.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,19 @@

import click

from .base_exporter import BaseExporter


class KeaSocket:
def __init__(self, sock_path):
try:
if not os.access(sock_path, os.F_OK):
raise FileNotFoundError()
if not os.access(sock_path, os.R_OK | os.W_OK):
raise PermissionError()
self.sock_path = os.path.abspath(sock_path)
except FileNotFoundError:
click.echo(
f"Socket at {sock_path} does not exist. Is Kea running?",
file=sys.stderr,
)
sys.exit(1)
except PermissionError:
click.echo(f"Socket at {sock_path} is not read-/writeable.", file=sys.stderr)
sys.exit(1)
from kea_exporter import DHCPVersion


class KeaSocketClient:
def __init__(self, sock_path, **kwargs):
super().__init__()

if not os.access(sock_path, os.F_OK):
raise FileNotFoundError(f"Unix domain socket does not exist at {sock_path}")
if not os.access(sock_path, os.R_OK | os.W_OK):
raise PermissionError(f"No read/write permissions on Unix domain socket at {sock_path}")

self.sock_path = os.path.abspath(sock_path)

self.version = None
self.config = None
Expand All @@ -48,16 +41,18 @@ def stats(self):
# unfortunately we're reloading more often now as a workaround.
self.reload()

return self.query("statistic-get-all")
arguments = self.query("statistic-get-all").get("arguments", {})

yield self.dhcp_version, arguments, self.subnets

def reload(self):
self.config = self.query("config-get")["arguments"]

if "Dhcp4" in self.config:
self.dhcp_version = BaseExporter.DHCPVersion.DHCP4
self.dhcp_version = DHCPVersion.DHCP4
subnets = self.config["Dhcp4"]["subnet4"]
elif "Dhcp6" in self.config:
self.dhcp_version = BaseExporter.DHCPVersion.DHCP6
self.dhcp_version = DHCPVersion.DHCP6
subnets = self.config["Dhcp6"]["subnet6"]
else:
click.echo(
Expand All @@ -68,15 +63,3 @@ def reload(self):

# create subnet map
self.subnets = {subnet["id"]: subnet for subnet in subnets}


class KeaSocketExporter(BaseExporter):
def __init__(self, sockets, **kwargs):
super().__init__()

# kea instances
self.kea_instances = [KeaSocket(socket) for socket in sockets]

def update(self):
for kea in self.kea_instances:
self.parse_metrics(kea.dhcp_version, kea.stats().get("arguments"), kea.subnets)
Loading